loading
Generated 2025-10-20T02:36:57-06:00

All Files ( 1.85% covered at 0.02 hits/line )

406 files in total.
42378 relevant lines, 783 lines covered and 41595 lines missed. ( 1.85% )
1131 total branches, 11 branches covered and 1120 branches missed. ( 0.97% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/channels/application_cable/channel.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/channels/application_cable/connection.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/channels/builder_preview_channel.rb 0.00 % 41 29 0 29 0.00 100.00 % 0 0 0
app/channels/realtime_analytics_channel.rb 0.00 % 39 31 0 31 0.00 100.00 % 0 0 0
app/constraints/admin_constraint.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/admin/access_levels_controller.rb 0.00 % 137 119 0 119 0.00 100.00 % 0 0 0
app/controllers/admin/ai_agents_controller.rb 0.00 % 136 100 0 100 0.00 100.00 % 0 0 0
app/controllers/admin/ai_demo_controller.rb 0.00 % 10 4 0 4 0.00 100.00 % 0 0 0
app/controllers/admin/ai_providers_controller.rb 0.00 % 77 54 0 54 0.00 100.00 % 0 0 0
app/controllers/admin/analytics_controller.rb 0.00 % 649 518 0 518 0.00 100.00 % 0 0 0
app/controllers/admin/api_docs_controller.rb 0.00 % 479 447 0 447 0.00 100.00 % 0 0 0
app/controllers/admin/base_controller.rb 0.00 % 37 25 0 25 0.00 100.00 % 0 0 0
app/controllers/admin/builder_controller.rb 0.00 % 1419 1076 0 1076 0.00 100.00 % 0 0 0
app/controllers/admin/bulk_optimization_controller.rb 0.00 % 267 205 0 205 0.00 100.00 % 0 0 0
app/controllers/admin/cache_controller.rb 0.00 % 253 214 0 214 0.00 100.00 % 0 0 0
app/controllers/admin/categories_controller.rb 0.00 % 125 97 0 97 0.00 100.00 % 0 0 0
app/controllers/admin/channels_controller.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/controllers/admin/comments_controller.rb 0.00 % 266 232 0 232 0.00 100.00 % 0 0 0
app/controllers/admin/consent/consent_configurations_controller.rb 0.00 % 142 101 0 101 0.00 100.00 % 0 0 0
app/controllers/admin/consent/consent_controller.rb 0.00 % 418 328 0 328 0.00 100.00 % 0 0 0
app/controllers/admin/content_analytics_controller.rb 0.00 % 164 126 0 126 0.00 100.00 % 0 0 0
app/controllers/admin/content_types_controller.rb 0.00 % 116 90 0 90 0.00 100.00 % 0 0 0
app/controllers/admin/dashboard_controller.rb 0.00 % 12 11 0 11 0.00 100.00 % 0 0 0
app/controllers/admin/email_logs_controller.rb 0.00 % 28 22 0 22 0.00 100.00 % 0 0 0
app/controllers/admin/field_groups_controller.rb 0.00 % 154 114 0 114 0.00 100.00 % 0 0 0
app/controllers/admin/fonts_controller.rb 0.00 % 147 106 0 106 0.00 100.00 % 0 0 0
app/controllers/admin/gdpr/gdpr_controller.rb 0.00 % 368 299 0 299 0.00 100.00 % 0 0 0
app/controllers/admin/gdpr_controller.rb 0.00 % 355 291 0 291 0.00 100.00 % 0 0 0
app/controllers/admin/geolocation_settings_controller.rb 0.00 % 187 144 0 144 0.00 100.00 % 0 0 0
app/controllers/admin/image_optimization_analytics_controller.rb 0.00 % 118 91 0 91 0.00 100.00 % 0 0 0
app/controllers/admin/integrations_controller.rb 0.00 % 162 131 0 131 0.00 100.00 % 0 0 0
app/controllers/admin/logs_controller.rb 0.00 % 160 111 0 111 0.00 100.00 % 0 0 0
app/controllers/admin/mcp_settings_controller.rb 0.00 % 368 345 0 345 0.00 100.00 % 0 0 0
app/controllers/admin/media_controller.rb 0.00 % 141 109 0 109 0.00 100.00 % 0 0 0
app/controllers/admin/menus_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/admin/oauth_controller.rb 0.00 % 382 307 0 307 0.00 100.00 % 0 0 0
app/controllers/admin/page_templates_controller.rb 0.00 % 140 101 0 101 0.00 100.00 % 0 0 0
app/controllers/admin/pages_controller.rb 0.00 % 212 178 0 178 0.00 100.00 % 0 0 0
app/controllers/admin/passwords_controller.rb 0.00 % 9 7 0 7 0.00 100.00 % 0 0 0
app/controllers/admin/pixels_controller.rb 0.00 % 122 82 0 82 0.00 100.00 % 0 0 0
app/controllers/admin/plugin_pages_controller.rb 0.00 % 78 55 0 55 0.00 100.00 % 0 0 0
app/controllers/admin/plugins_controller.rb 0.00 % 611 491 0 491 0.00 100.00 % 0 0 0
app/controllers/admin/posts_controller.rb 0.00 % 390 320 0 320 0.00 100.00 % 0 0 0
app/controllers/admin/profile_controller.rb 0.00 % 59 42 0 42 0.00 100.00 % 0 0 0
app/controllers/admin/redirects_controller.rb 0.00 % 169 119 0 119 0.00 100.00 % 0 0 0
app/controllers/admin/security_controller.rb 0.00 % 71 46 0 46 0.00 100.00 % 0 0 0
app/controllers/admin/sessions_controller.rb 0.00 % 26 17 0 17 0.00 100.00 % 0 0 0
app/controllers/admin/settings/storage_controller.rb 0.00 % 19 14 0 14 0.00 100.00 % 0 0 0
app/controllers/admin/settings/upload_security_controller.rb 0.00 % 36 28 0 28 0.00 100.00 % 0 0 0
app/controllers/admin/settings_controller.rb 0.00 % 555 420 0 420 0.00 100.00 % 0 0 0
app/controllers/admin/shortcodes_controller.rb 0.00 % 115 100 0 100 0.00 100.00 % 0 0 0
app/controllers/admin/site_settings_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms/forms_controller.rb 0.00 % 165 130 0 130 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms/submissions_controller.rb 0.00 % 205 161 0 161 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms_controller.rb 0.00 % 667 558 0 558 0.00 100.00 % 0 0 0
app/controllers/admin/storage_providers_controller.rb 0.00 % 98 75 0 75 0.00 100.00 % 0 0 0
app/controllers/admin/subscribers_controller.rb 0.00 % 205 144 0 144 0.00 100.00 % 0 0 0
app/controllers/admin/system/api_tokens_controller.rb 0.00 % 81 60 0 60 0.00 100.00 % 0 0 0
app/controllers/admin/system/channel_overrides_controller.rb 0.00 % 129 107 0 107 0.00 100.00 % 0 0 0
app/controllers/admin/system/channels_controller.rb 0.00 % 178 165 0 165 0.00 100.00 % 0 0 0
app/controllers/admin/system/headless_controller.rb 0.00 % 42 32 0 32 0.00 100.00 % 0 0 0
app/controllers/admin/tags_controller.rb 0.00 % 100 77 0 77 0.00 100.00 % 0 0 0
app/controllers/admin/taxonomies_controller.rb 0.00 % 58 40 0 40 0.00 100.00 % 0 0 0
app/controllers/admin/template_customizer_controller.rb 0.00 % 384 304 0 304 0.00 100.00 % 0 0 0
app/controllers/admin/terms_controller.rb 0.00 % 55 40 0 40 0.00 100.00 % 0 0 0
app/controllers/admin/theme_editor_controller.rb 0.00 % 207 169 0 169 0.00 100.00 % 0 0 0
app/controllers/admin/themes_controller.rb 0.00 % 248 179 0 179 0.00 100.00 % 0 0 0
app/controllers/admin/tools/erase_personal_data_controller.rb 0.00 % 91 63 0 63 0.00 100.00 % 0 0 0
app/controllers/admin/tools/export_controller.rb 0.00 % 67 46 0 46 0.00 100.00 % 0 0 0
app/controllers/admin/tools/export_personal_data_controller.rb 0.00 % 77 52 0 52 0.00 100.00 % 0 0 0
app/controllers/admin/tools/import_controller.rb 0.00 % 79 53 0 53 0.00 100.00 % 0 0 0
app/controllers/admin/tools/shortcuts_controller.rb 0.00 % 96 69 0 69 0.00 100.00 % 0 0 0
app/controllers/admin/tools/site_health_controller.rb 0.00 % 245 185 0 185 0.00 100.00 % 0 0 0
app/controllers/admin/trash_controller.rb 0.00 % 51 41 0 41 0.00 100.00 % 0 0 0
app/controllers/admin/trash_settings_controller.rb 0.00 % 58 45 0 45 0.00 100.00 % 0 0 0
app/controllers/admin/updates_controller.rb 0.00 % 38 24 0 24 0.00 100.00 % 0 0 0
app/controllers/admin/user_preferences_controller.rb 0.00 % 16 15 0 15 0.00 100.00 % 0 0 0
app/controllers/admin/users_controller.rb 0.00 % 350 276 0 276 0.00 100.00 % 0 0 0
app/controllers/admin/webhooks_controller.rb 0.00 % 247 199 0 199 0.00 100.00 % 0 0 0
app/controllers/admin/widgets_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/analytics_controller.rb 0.00 % 107 77 0 77 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_agents_controller.rb 0.00 % 202 170 0 170 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_providers_controller.rb 0.00 % 164 135 0 135 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_seo_controller.rb 0.00 % 108 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/analytics_controller.rb 0.00 % 222 190 0 190 0.00 100.00 % 0 0 0
app/controllers/api/v1/auth_controller.rb 0.00 % 98 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/base_controller.rb 0.00 % 40 27 0 27 0.00 100.00 % 0 0 0
app/controllers/api/v1/categories_controller.rb 0.00 % 106 77 0 77 0.00 100.00 % 0 0 0
app/controllers/api/v1/channels_controller.rb 0.00 % 117 96 0 96 0.00 100.00 % 0 0 0
app/controllers/api/v1/comments_controller.rb 0.00 % 223 165 0 165 0.00 100.00 % 0 0 0
app/controllers/api/v1/consent_controller.rb 0.00 % 281 227 0 227 0.00 100.00 % 0 0 0
app/controllers/api/v1/content_types_controller.rb 0.00 % 64 50 0 50 0.00 100.00 % 0 0 0
app/controllers/api/v1/docs_controller.rb 0.00 % 143 132 0 132 0.00 100.00 % 0 0 0
app/controllers/api/v1/gdpr_controller.rb 0.00 % 326 273 0 273 0.00 100.00 % 0 0 0
app/controllers/api/v1/image_optimization_controller.rb 0.00 % 238 196 0 196 0.00 100.00 % 0 0 0
app/controllers/api/v1/mcp_controller.rb 0.00 % 1934 1728 0 1728 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller.rb 0.00 % 161 111 0 111 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller_new.rb 0.00 % 122 81 0 81 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller_old.rb 0.00 % 241 188 0 188 0.00 100.00 % 0 0 0
app/controllers/api/v1/menus_controller.rb 0.00 % 112 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/meta_fields_controller.rb 0.00 % 239 206 0 206 0.00 100.00 % 0 0 0
app/controllers/api/v1/openai_controller.rb 0.00 % 265 219 0 219 0.00 100.00 % 0 0 0
app/controllers/api/v1/pages_controller.rb 0.00 % 182 126 0 126 0.00 100.00 % 0 0 0
app/controllers/api/v1/posts_controller.rb 0.00 % 202 142 0 142 0.00 100.00 % 0 0 0
app/controllers/api/v1/settings_controller.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/controllers/api/v1/simple_controller.rb 0.00 % 10 9 0 9 0.00 100.00 % 0 0 0
app/controllers/api/v1/subscribers_controller.rb 0.00 % 185 129 0 129 0.00 100.00 % 0 0 0
app/controllers/api/v1/system_controller.rb 0.00 % 82 74 0 74 0.00 100.00 % 0 0 0
app/controllers/api/v1/tags_controller.rb 0.00 % 78 61 0 61 0.00 100.00 % 0 0 0
app/controllers/api/v1/taxonomies_controller.rb 0.00 % 139 98 0 98 0.00 100.00 % 0 0 0
app/controllers/api/v1/terms_controller.rb 0.00 % 122 87 0 87 0.00 100.00 % 0 0 0
app/controllers/api/v1/test_controller.rb 0.00 % 36 31 0 31 0.00 100.00 % 0 0 0
app/controllers/api/v1/themes_controller.rb 0.00 % 110 91 0 91 0.00 100.00 % 0 0 0
app/controllers/api/v1/uploads_controller.rb 0.00 % 212 172 0 172 0.00 100.00 % 0 0 0
app/controllers/application_controller.rb 0.00 % 61 39 0 39 0.00 100.00 % 0 0 0
app/controllers/comments_controller.rb 0.00 % 34 24 0 24 0.00 100.00 % 0 0 0
app/controllers/concerns/liquid_renderable.rb 0.00 % 59 43 0 43 0.00 100.00 % 0 0 0
app/controllers/concerns/themeable.rb 0.00 % 45 30 0 30 0.00 100.00 % 0 0 0
app/controllers/csp_reports_controller.rb 0.00 % 31 16 0 16 0.00 100.00 % 0 0 0
app/controllers/feeds_controller.rb 0.00 % 101 75 0 75 0.00 100.00 % 0 0 0
app/controllers/gdpr_controller.rb 0.00 % 252 189 0 189 0.00 100.00 % 0 0 0
app/controllers/graphql_controller.rb 0.00 % 52 38 0 38 0.00 100.00 % 0 0 0
app/controllers/home_controller.rb 0.00 % 115 84 0 84 0.00 100.00 % 0 0 0
app/controllers/omniauth_callbacks_controller.rb 0.00 % 151 109 0 109 0.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 0.00 % 85 57 0 57 0.00 100.00 % 0 0 0
app/controllers/plugins/slick_forms/forms_controller.rb 0.00 % 39 24 0 24 0.00 100.00 % 0 0 0
app/controllers/plugins/slick_forms/submissions_controller.rb 0.00 % 105 69 0 69 0.00 100.00 % 0 0 0
app/controllers/posts_controller.rb 0.00 % 230 185 0 185 0.00 100.00 % 0 0 0
app/controllers/preview_controller.rb 0.00 % 48 37 0 37 0.00 100.00 % 0 0 0
app/controllers/slick_forms_controller.rb 0.00 % 332 248 0 248 0.00 100.00 % 0 0 0
app/controllers/subscribers_controller.rb 0.00 % 77 54 0 54 0.00 100.00 % 0 0 0
app/controllers/theme_assets_controller.rb 0.00 % 37 24 0 24 0.00 100.00 % 0 0 0
app/controllers/themes_controller.rb 0.00 % 79 57 0 57 0.00 100.00 % 0 0 0
app/controllers/users/confirmations_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/omniauth_callbacks_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/passwords_controller.rb 0.00 % 34 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/registrations_controller.rb 0.00 % 62 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/sessions_controller.rb 0.00 % 27 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/unlocks_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0
app/graphql/mutations/base_mutation.rb 0.00 % 10 8 0 8 0.00 100.00 % 0 0 0
app/graphql/mutations/gdpr_mutations.rb 0.00 % 245 199 0 199 0.00 100.00 % 0 0 0
app/graphql/mutations/meta_fields/create_meta_field.rb 0.00 % 64 51 0 51 0.00 100.00 % 0 0 0
app/graphql/railspress_schema.rb 0.00 % 45 19 0 19 0.00 100.00 % 0 0 0
app/graphql/resolvers/base_resolver.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/resolvers/channel_resolver.rb 0.00 % 21 17 0 17 0.00 100.00 % 0 0 0
app/graphql/resolvers/channels_resolver.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/graphql/resolvers/image_optimization_resolver.rb 0.00 % 179 142 0 142 0.00 100.00 % 0 0 0
app/graphql/resolvers/media_resolver.rb 0.00 % 48 33 0 33 0.00 100.00 % 0 0 0
app/graphql/resolvers/pages_resolver.rb 0.00 % 55 38 0 38 0.00 100.00 % 0 0 0
app/graphql/resolvers/posts_resolver.rb 0.00 % 57 40 0 40 0.00 100.00 % 0 0 0
app/graphql/types/ai_agent_type.rb 0.00 % 69 50 0 50 0.00 100.00 % 0 0 0
app/graphql/types/analytics_type.rb 0.00 % 126 103 0 103 0.00 100.00 % 0 0 0
app/graphql/types/base_argument.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_connection.rb 0.00 % 8 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_edge.rb 0.00 % 8 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_enum.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_field.rb 0.00 % 7 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_input_object.rb 0.00 % 7 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_interface.rb 0.00 % 11 8 0 8 0.00 100.00 % 0 0 0
app/graphql/types/base_object.rb 0.00 % 9 7 0 7 0.00 100.00 % 0 0 0
app/graphql/types/base_scalar.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_union.rb 0.00 % 8 6 0 6 0.00 100.00 % 0 0 0
app/graphql/types/category_type.rb 0.00 % 31 23 0 23 0.00 100.00 % 0 0 0
app/graphql/types/channel_override_type.rb 0.00 % 48 38 0 38 0.00 100.00 % 0 0 0
app/graphql/types/channel_type.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/graphql/types/comment_type.rb 0.00 % 42 25 0 25 0.00 100.00 % 0 0 0
app/graphql/types/content_type_type.rb 0.00 % 39 30 0 30 0.00 100.00 % 0 0 0
app/graphql/types/gdpr_type.rb 0.00 % 139 113 0 113 0.00 100.00 % 0 0 0
app/graphql/types/image_optimization_log_type.rb 0.00 % 89 78 0 78 0.00 100.00 % 0 0 0
app/graphql/types/media_type.rb 0.00 % 58 40 0 40 0.00 100.00 % 0 0 0
app/graphql/types/medium_type.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/meta_field_input_type.rb 0.00 % 26 17 0 17 0.00 100.00 % 0 0 0
app/graphql/types/meta_field_type.rb 0.00 % 55 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/mutation_type.rb 0.00 % 94 64 0 64 0.00 100.00 % 0 0 0
app/graphql/types/node_type.rb 0.00 % 9 6 0 6 0.00 100.00 % 0 0 0
app/graphql/types/page_type.rb 0.00 % 46 36 0 36 0.00 100.00 % 0 0 0
app/graphql/types/post_type.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/graphql/types/query_type.rb 0.00 % 40 26 0 26 0.00 100.00 % 0 0 0
app/graphql/types/search_results_type.rb 0.00 % 17 8 0 8 0.00 100.00 % 0 0 0
app/graphql/types/storage_provider_type.rb 0.00 % 37 27 0 27 0.00 100.00 % 0 0 0
app/graphql/types/subscriber_type.rb 0.00 % 32 20 0 20 0.00 100.00 % 0 0 0
app/graphql/types/tag_type.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/graphql/types/taxonomy_type.rb 0.00 % 46 31 0 31 0.00 100.00 % 0 0 0
app/graphql/types/term_type.rb 0.00 % 51 33 0 33 0.00 100.00 % 0 0 0
app/graphql/types/upload_type.rb 0.00 % 59 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/user_type.rb 0.00 % 102 74 0 74 0.00 100.00 % 0 0 0
app/helpers/admin/ai_agents_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/ai_helper.rb 35.29 % 45 17 6 11 0.35 100.00 % 0 0 0
app/helpers/admin/ai_providers_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/categories_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/comments_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/dashboard_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/media_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/menus_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/pages_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/plugins_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/posts_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/settings/redis_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/site_settings_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/tags_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/taxonomies_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/template_customizer_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/terms_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/themes_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/webhooks_helper.rb 50.00 % 24 4 2 2 0.50 100.00 % 0 0 0
app/helpers/admin/widgets_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin_assets_helper.rb 50.00 % 77 12 6 6 0.50 100.00 % 0 0 0
app/helpers/ai_text_generator_helper.rb 21.88 % 105 32 7 25 0.22 0.00 % 4 0 4
app/helpers/analytics_helper.rb 5.46 % 572 348 19 329 0.05 0.00 % 279 0 279
app/helpers/api/v1/ai_agents_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/api/v1/docs_helper.rb 40.00 % 28 10 4 6 0.40 0.00 % 5 0 5
app/helpers/appearance_helper.rb 24.49 % 307 49 12 37 0.24 0.00 % 18 0 18
app/helpers/application_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/consent_helper.rb 24.72 % 263 89 22 67 0.25 0.00 % 58 0 58
app/helpers/editor_helper.rb 25.00 % 77 24 6 18 0.25 0.00 % 16 0 16
app/helpers/home_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/image_optimization_helper.rb 21.62 % 113 37 8 29 0.22 0.00 % 21 0 21
app/helpers/monaco_helper.rb 33.33 % 74 18 6 12 0.33 0.00 % 7 0 7
app/helpers/pages_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/pixels_helper.rb 23.53 % 49 17 4 13 0.24 0.00 % 6 0 6
app/helpers/plugin_blocks_helper.rb 22.22 % 81 18 4 14 0.22 0.00 % 6 0 6
app/helpers/plugin_settings_helper.rb 17.33 % 182 75 13 62 0.17 0.00 % 29 0 29
app/helpers/posts_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/seo_helper.rb 11.11 % 74 36 4 32 0.11 0.00 % 18 0 18
app/helpers/status_helper.rb 36.36 % 112 22 8 14 0.36 100.00 % 0 0 0
app/helpers/taxonomy_helper.rb 27.50 % 190 80 22 58 0.28 0.00 % 42 0 42
app/helpers/toggle_switch_helper.rb 27.59 % 114 58 16 42 0.28 0.00 % 26 0 26
app/jobs/advanced_analytics_processing_job.rb 0.00 % 413 312 0 312 0.00 100.00 % 0 0 0
app/jobs/analytics_archive_job.rb 0.00 % 23 15 0 15 0.00 100.00 % 0 0 0
app/jobs/analytics_processing_job.rb 0.00 % 34 25 0 25 0.00 100.00 % 0 0 0
app/jobs/analytics_retention_job.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/jobs/application_job.rb 0.00 % 7 2 0 2 0.00 100.00 % 0 0 0
app/jobs/check_updates_job.rb 0.00 % 37 16 0 16 0.00 100.00 % 0 0 0
app/jobs/content_analytics_update_job.rb 0.00 % 88 52 0 52 0.00 100.00 % 0 0 0
app/jobs/deliver_webhook_job.rb 0.00 % 70 42 0 42 0.00 100.00 % 0 0 0
app/jobs/maxmind_update_job.rb 0.00 % 59 37 0 37 0.00 100.00 % 0 0 0
app/jobs/optimize_image_job.rb 0.00 % 31 23 0 23 0.00 100.00 % 0 0 0
app/jobs/plugin_task_worker_job.rb 0.00 % 33 25 0 25 0.00 100.00 % 0 0 0
app/jobs/slick_forms_integration_job.rb 0.00 % 246 186 0 186 0.00 100.00 % 0 0 0
app/jobs/slick_forms_notification_job.rb 0.00 % 135 102 0 102 0.00 100.00 % 0 0 0
app/jobs/webhook_job.rb 0.00 % 61 44 0 44 0.00 100.00 % 0 0 0
app/mailers/application_mailer.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/mailers/email_logging_interceptor.rb 23.08 % 41 13 3 10 0.23 0.00 % 12 0 12
app/mailers/slick_forms_mailer.rb 0.00 % 50 37 0 37 0.00 100.00 % 0 0 0
app/mailers/test_mailer.rb 0.00 % 22 12 0 12 0.00 100.00 % 0 0 0
app/middleware/allow_iframe_for_logs.rb 33.33 % 18 9 3 6 0.33 0.00 % 2 0 2
app/middleware/analytics_tracker.rb 35.56 % 141 45 16 29 0.36 0.00 % 14 0 14
app/middleware/channel_detection_middleware.rb 28.57 % 73 35 10 25 0.29 0.00 % 16 0 16
app/middleware/headless_mode_handler.rb 26.67 % 170 30 8 22 0.27 0.00 % 22 0 22
app/middleware/redirect_handler.rb 18.92 % 107 37 7 30 0.19 0.00 % 28 0 28
app/models/admin_notification.rb 0.00 % 2 2 0 2 0.00 100.00 % 0 0 0
app/models/ai_agent.rb 0.00 % 166 117 0 117 0.00 100.00 % 0 0 0
app/models/ai_provider.rb 0.00 % 46 38 0 38 0.00 100.00 % 0 0 0
app/models/ai_usage.rb 0.00 % 35 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_audit_log.rb 0.00 % 39 30 0 30 0.00 100.00 % 0 0 0
app/models/analytics_consent.rb 0.00 % 37 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_data_deletion.rb 0.00 % 35 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_event.rb 0.00 % 71 55 0 55 0.00 100.00 % 0 0 0
app/models/api_token.rb 0.00 % 92 71 0 71 0.00 100.00 % 0 0 0
app/models/application_record.rb 100.00 % 3 2 2 0 1.00 100.00 % 0 0 0
app/models/archived_analytics_event.rb 0.00 % 49 37 0 37 0.00 100.00 % 0 0 0
app/models/archived_pageview.rb 0.00 % 59 47 0 47 0.00 100.00 % 0 0 0
app/models/builder_page.rb 0.00 % 159 123 0 123 0.00 100.00 % 0 0 0
app/models/builder_page_section.rb 0.00 % 135 110 0 110 0.00 100.00 % 0 0 0
app/models/builder_theme.rb 0.00 % 644 460 0 460 0.00 100.00 % 0 0 0
app/models/builder_theme_file.rb 0.00 % 119 93 0 93 0.00 100.00 % 0 0 0
app/models/builder_theme_section.rb 0.00 % 123 101 0 101 0.00 100.00 % 0 0 0
app/models/builder_theme_snapshot.rb 0.00 % 159 110 0 110 0.00 100.00 % 0 0 0
app/models/channel.rb 0.00 % 118 87 0 87 0.00 100.00 % 0 0 0
app/models/channel_override.rb 0.00 % 67 49 0 49 0.00 100.00 % 0 0 0
app/models/comment.rb 0.00 % 154 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/channel_detection.rb 0.00 % 162 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/has_taxonomies.rb 0.00 % 111 71 0 71 0.00 100.00 % 0 0 0
app/models/concerns/metable.rb 40.30 % 132 67 27 40 0.40 0.00 % 15 0 15
app/models/concerns/railspress/channel_detection.rb 0.00 % 161 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/sanitizable.rb 0.00 % 48 24 0 24 0.00 100.00 % 0 0 0
app/models/concerns/seo_optimizable.rb 0.00 % 135 89 0 89 0.00 100.00 % 0 0 0
app/models/concerns/trashable.rb 0.00 % 69 47 0 47 0.00 100.00 % 0 0 0
app/models/consent_configuration.rb 0.00 % 612 508 0 508 0.00 100.00 % 0 0 0
app/models/content_type.rb 0.00 % 84 59 0 59 0.00 100.00 % 0 0 0
app/models/current.rb 0.00 % 7 3 0 3 0.00 100.00 % 0 0 0
app/models/custom_field.rb 0.00 % 134 99 0 99 0.00 100.00 % 0 0 0
app/models/custom_field_value.rb 0.00 % 64 49 0 49 0.00 100.00 % 0 0 0
app/models/custom_font.rb 0.00 % 160 112 0 112 0.00 100.00 % 0 0 0
app/models/email_log.rb 0.00 % 72 55 0 55 0.00 100.00 % 0 0 0
app/models/export_job.rb 0.00 % 18 14 0 14 0.00 100.00 % 0 0 0
app/models/field_group.rb 0.00 % 165 125 0 125 0.00 100.00 % 0 0 0
app/models/image_optimization_log.rb 0.00 % 293 231 0 231 0.00 100.00 % 0 0 0
app/models/import_job.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/models/medium.rb 0.00 % 154 111 0 111 0.00 100.00 % 0 0 0
app/models/menu.rb 0.00 % 19 10 0 10 0.00 100.00 % 0 0 0
app/models/menu_item.rb 0.00 % 29 17 0 17 0.00 100.00 % 0 0 0
app/models/meta_field.rb 0.00 % 144 97 0 97 0.00 100.00 % 0 0 0
app/models/oauth_account.rb 0.00 % 100 78 0 78 0.00 100.00 % 0 0 0
app/models/page.rb 0.00 % 230 155 0 155 0.00 100.00 % 0 0 0
app/models/page_template.rb 0.00 % 178 144 0 144 0.00 100.00 % 0 0 0
app/models/pageview.rb 0.00 % 427 320 0 320 0.00 100.00 % 0 0 0
app/models/personal_data_erasure_request.rb 0.00 % 28 21 0 21 0.00 100.00 % 0 0 0
app/models/personal_data_export_request.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/models/pixel.rb 0.00 % 367 324 0 324 0.00 100.00 % 0 0 0
app/models/plugin.rb 71.43 % 30 14 10 4 0.79 0.00 % 2 0 2
app/models/plugin_setting.rb 0.00 % 64 49 0 49 0.00 100.00 % 0 0 0
app/models/post.rb 0.00 % 338 232 0 232 0.00 100.00 % 0 0 0
app/models/published_theme_file.rb 0.00 % 10 8 0 8 0.00 100.00 % 0 0 0
app/models/published_theme_version.rb 0.00 % 62 45 0 45 0.00 100.00 % 0 0 0
app/models/redirect.rb 0.00 % 196 140 0 140 0.00 100.00 % 0 0 0
app/models/shortcut.rb 0.00 % 39 28 0 28 0.00 100.00 % 0 0 0
app/models/site_setting.rb 54.55 % 45 22 12 10 1.27 16.67 % 12 2 10
app/models/slick_form.rb 0.00 % 68 45 0 45 0.00 100.00 % 0 0 0
app/models/slick_form_submission.rb 0.00 % 119 88 0 88 0.00 100.00 % 0 0 0
app/models/storage_provider.rb 0.00 % 109 86 0 86 0.00 100.00 % 0 0 0
app/models/subscriber.rb 0.00 % 232 165 0 165 0.00 100.00 % 0 0 0
app/models/taxonomy.rb 0.00 % 72 49 0 49 0.00 100.00 % 0 0 0
app/models/template.rb 0.00 % 70 55 0 55 0.00 100.00 % 0 0 0
app/models/tenant.rb 54.43 % 149 79 43 36 0.54 0.00 % 16 0 16
app/models/term.rb 0.00 % 104 70 0 70 0.00 100.00 % 0 0 0
app/models/term_relationship.rb 0.00 % 18 13 0 13 0.00 100.00 % 0 0 0
app/models/theme.rb 41.54 % 142 65 27 38 0.43 0.00 % 20 0 20
app/models/theme_file.rb 0.00 % 100 79 0 79 0.00 100.00 % 0 0 0
app/models/theme_file_version.rb 0.00 % 55 43 0 43 0.00 100.00 % 0 0 0
app/models/theme_preview.rb 0.00 % 135 94 0 94 0.00 100.00 % 0 0 0
app/models/theme_preview_block.rb 0.00 % 22 14 0 14 0.00 100.00 % 0 0 0
app/models/theme_preview_file.rb 0.00 % 86 65 0 65 0.00 100.00 % 0 0 0
app/models/theme_preview_section.rb 0.00 % 101 68 0 68 0.00 100.00 % 0 0 0
app/models/theme_version.rb 0.00 % 143 103 0 103 0.00 100.00 % 0 0 0
app/models/theme_version_file.rb 0.00 % 80 62 0 62 0.00 100.00 % 0 0 0
app/models/trash_setting.rb 0.00 % 48 33 0 33 0.00 100.00 % 0 0 0
app/models/upload.rb 0.00 % 231 157 0 157 0.00 100.00 % 0 0 0
app/models/upload_security.rb 0.00 % 215 156 0 156 0.00 100.00 % 0 0 0
app/models/user.rb 51.00 % 212 100 51 49 0.51 0.00 % 17 0 17
app/models/user_consent.rb 0.00 % 59 45 0 45 0.00 100.00 % 0 0 0
app/models/user_notification.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/webhook.rb 0.00 % 107 73 0 73 0.00 100.00 % 0 0 0
app/models/webhook_delivery.rb 0.00 % 94 63 0 63 0.00 100.00 % 0 0 0
app/models/widget.rb 0.00 % 43 29 0 29 0.00 100.00 % 0 0 0
app/policies/application_policy.rb 0.00 % 53 39 0 39 0.00 100.00 % 0 0 0
app/services/advanced_analytics_service.rb 0.00 % 475 344 0 344 0.00 100.00 % 0 0 0
app/services/ai_helper.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/services/ai_service.rb 0.00 % 161 128 0 128 0.00 100.00 % 0 0 0
app/services/akismet_service.rb 0.00 % 97 76 0 76 0.00 100.00 % 0 0 0
app/services/analytics_archive_service.rb 0.00 % 291 235 0 235 0.00 100.00 % 0 0 0
app/services/analytics_retention_service.rb 0.00 % 89 63 0 63 0.00 100.00 % 0 0 0
app/services/analytics_security_service.rb 0.00 % 535 374 0 374 0.00 100.00 % 0 0 0
app/services/analytics_service.rb 0.00 % 503 414 0 414 0.00 100.00 % 0 0 0
app/services/builder_liquid_renderer.rb 0.00 % 964 716 0 716 0.00 100.00 % 0 0 0
app/services/builder_theme_service.rb 0.00 % 235 155 0 155 0.00 100.00 % 0 0 0
app/services/content_analytics_service.rb 0.00 % 325 239 0 239 0.00 100.00 % 0 0 0
app/services/documentation_sync_service.rb 0.00 % 72 45 0 45 0.00 100.00 % 0 0 0
app/services/frontend_renderer_service.rb 0.00 % 151 110 0 110 0.00 100.00 % 0 0 0
app/services/frontend_theme_renderer.rb 0.00 % 121 91 0 91 0.00 100.00 % 0 0 0
app/services/gdpr_compliance_service.rb 0.00 % 455 351 0 351 0.00 100.00 % 0 0 0
app/services/gdpr_service.rb 0.00 % 403 327 0 327 0.00 100.00 % 0 0 0
app/services/geolocation_service.rb 0.00 % 364 293 0 293 0.00 100.00 % 0 0 0
app/services/image_optimization_service.rb 0.00 % 573 435 0 435 0.00 100.00 % 0 0 0
app/services/liquid_template_renderer.rb 16.67 % 176 66 11 55 0.17 0.00 % 17 0 17
app/services/liquid_template_version_renderer.rb 0.00 % 184 135 0 135 0.00 100.00 % 0 0 0
app/services/maxmind_updater_service.rb 0.00 % 323 243 0 243 0.00 100.00 % 0 0 0
app/services/oauth_provider_service.rb 0.00 % 103 79 0 79 0.00 100.00 % 0 0 0
app/services/plugin_reload_service.rb 0.00 % 44 28 0 28 0.00 100.00 % 0 0 0
app/services/post_by_email_service.rb 0.00 % 228 160 0 160 0.00 100.00 % 0 0 0
app/services/realtime_analytics_service.rb 0.00 % 52 49 0 49 0.00 100.00 % 0 0 0
app/services/screenshot_service.rb 0.00 % 121 83 0 83 0.00 100.00 % 0 0 0
app/services/storage_configuration_service.rb 0.00 % 177 121 0 121 0.00 100.00 % 0 0 0
app/services/theme_file_manager.rb 0.00 % 312 218 0 218 0.00 100.00 % 0 0 0
app/services/theme_preview_renderer.rb 0.00 % 190 137 0 137 0.00 100.00 % 0 0 0
app/services/theme_version_loader.rb 0.00 % 157 122 0 122 0.00 100.00 % 0 0 0
app/services/theme_version_service.rb 0.00 % 116 87 0 87 0.00 100.00 % 0 0 0
app/services/themes_manager.rb 0.00 % 736 530 0 530 0.00 100.00 % 0 0 0
app/workers/export_worker.rb 0.00 % 159 133 0 133 0.00 100.00 % 0 0 0
app/workers/import_worker.rb 0.00 % 200 158 0 158 0.00 100.00 % 0 0 0
app/workers/personal_data_erasure_worker.rb 0.00 % 157 106 0 106 0.00 100.00 % 0 0 0
app/workers/personal_data_export_worker.rb 0.00 % 89 69 0 69 0.00 100.00 % 0 0 0
app/workers/post_by_email_worker.rb 0.00 % 25 13 0 13 0.00 100.00 % 0 0 0
lib/development_plugin_watcher.rb 0.00 % 61 40 0 40 0.00 100.00 % 0 0 0
lib/generators/plugin_generator.rb 0.00 % 921 635 0 635 0.00 100.00 % 0 0 0
lib/plugins/PLUGIN_TEMPLATE.rb 0.00 % 161 66 0 66 0.00 100.00 % 0 0 0
lib/plugins/advanced_shortcodes/advanced_shortcodes.rb 0.00 % 243 193 0 193 0.00 100.00 % 0 0 0
lib/plugins/ai_seo/ai_seo.rb 0.00 % 559 430 0 430 0.00 100.00 % 0 0 0
lib/plugins/email_notifications/email_notifications.rb 0.00 % 173 136 0 136 0.00 100.00 % 0 0 0
lib/plugins/hello_tupac/hello_tupac.rb 0.00 % 86 69 0 69 0.00 100.00 % 0 0 0
lib/plugins/image_optimizer/image_optimizer.rb 0.00 % 48 24 0 24 0.00 100.00 % 0 0 0
lib/plugins/reading_time/reading_time.rb 0.00 % 70 42 0 42 0.00 100.00 % 0 0 0
lib/plugins/related_posts/related_posts.rb 0.00 % 127 90 0 90 0.00 100.00 % 0 0 0
lib/plugins/seo_optimizer_pro/seo_optimizer_pro.rb 0.00 % 74 39 0 39 0.00 100.00 % 0 0 0
lib/plugins/sitemap_generator/sitemap_generator.rb 0.00 % 93 66 0 66 0.00 100.00 % 0 0 0
lib/plugins/slick_forms/slick_forms.rb 0.00 % 626 471 0 471 0.00 100.00 % 0 0 0
lib/plugins/slick_forms_pro/slick_forms_pro.rb 0.00 % 538 397 0 397 0.00 100.00 % 0 0 0
lib/plugins/social_sharing/social_sharing.rb 0.00 % 197 149 0 149 0.00 100.00 % 0 0 0
lib/plugins/spam_protection/spam_protection.rb 0.00 % 118 70 0 70 0.00 100.00 % 0 0 0
lib/plugins/uploadcare/uploadcare.rb 0.00 % 325 244 0 244 0.00 100.00 % 0 0 0
lib/railspress/ai_agent_integration/channels.rb 0.00 % 184 131 0 131 0.00 100.00 % 0 0 0
lib/railspress/ai_agent_plugin_helper.rb 0.00 % 143 81 0 81 0.00 100.00 % 0 0 0
lib/railspress/html_sanitizer.rb 0.00 % 249 162 0 162 0.00 100.00 % 0 0 0
lib/railspress/liquid/consent_tags.rb 33.03 % 664 218 72 146 0.38 0.00 % 82 0 82
lib/railspress/liquid/image_optimization_tags.rb 33.33 % 373 99 33 66 0.33 0.00 % 26 0 26
lib/railspress/newsletter_shortcodes.rb 0.00 % 372 319 0 319 0.00 100.00 % 0 0 0
lib/railspress/plugin_api/channels.rb 0.00 % 128 91 0 91 0.00 100.00 % 0 0 0
lib/railspress/plugin_base.rb 27.55 % 1188 432 119 313 0.28 0.00 % 105 0 105
lib/railspress/plugin_blocks.rb 0.00 % 172 96 0 96 0.00 100.00 % 0 0 0
lib/railspress/plugin_jobs.rb 0.00 % 161 84 0 84 0.00 100.00 % 0 0 0
lib/railspress/plugin_system.rb 26.69 % 638 281 75 206 0.27 6.00 % 100 6 94
lib/railspress/settings_schema.rb 0.00 % 267 192 0 192 0.00 100.00 % 0 0 0
lib/railspress/shortcode_processor.rb 29.27 % 277 123 36 87 0.46 0.00 % 34 0 34
lib/railspress/theme_loader.rb 22.81 % 237 114 26 88 0.23 5.36 % 56 3 53
lib/railspress/update_checker.rb 0.00 % 141 104 0 104 0.00 100.00 % 0 0 0
lib/railspress/webhook_dispatcher.rb 0.00 % 143 116 0 116 0.00 100.00 % 0 0 0

Controllers ( 0.0% covered at 0.0 hits/line )

134 files in total.
18418 relevant lines, 0 lines covered and 18418 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/admin/access_levels_controller.rb 0.00 % 137 119 0 119 0.00 100.00 % 0 0 0
app/controllers/admin/ai_agents_controller.rb 0.00 % 136 100 0 100 0.00 100.00 % 0 0 0
app/controllers/admin/ai_demo_controller.rb 0.00 % 10 4 0 4 0.00 100.00 % 0 0 0
app/controllers/admin/ai_providers_controller.rb 0.00 % 77 54 0 54 0.00 100.00 % 0 0 0
app/controllers/admin/analytics_controller.rb 0.00 % 649 518 0 518 0.00 100.00 % 0 0 0
app/controllers/admin/api_docs_controller.rb 0.00 % 479 447 0 447 0.00 100.00 % 0 0 0
app/controllers/admin/base_controller.rb 0.00 % 37 25 0 25 0.00 100.00 % 0 0 0
app/controllers/admin/builder_controller.rb 0.00 % 1419 1076 0 1076 0.00 100.00 % 0 0 0
app/controllers/admin/bulk_optimization_controller.rb 0.00 % 267 205 0 205 0.00 100.00 % 0 0 0
app/controllers/admin/cache_controller.rb 0.00 % 253 214 0 214 0.00 100.00 % 0 0 0
app/controllers/admin/categories_controller.rb 0.00 % 125 97 0 97 0.00 100.00 % 0 0 0
app/controllers/admin/channels_controller.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/controllers/admin/comments_controller.rb 0.00 % 266 232 0 232 0.00 100.00 % 0 0 0
app/controllers/admin/consent/consent_configurations_controller.rb 0.00 % 142 101 0 101 0.00 100.00 % 0 0 0
app/controllers/admin/consent/consent_controller.rb 0.00 % 418 328 0 328 0.00 100.00 % 0 0 0
app/controllers/admin/content_analytics_controller.rb 0.00 % 164 126 0 126 0.00 100.00 % 0 0 0
app/controllers/admin/content_types_controller.rb 0.00 % 116 90 0 90 0.00 100.00 % 0 0 0
app/controllers/admin/dashboard_controller.rb 0.00 % 12 11 0 11 0.00 100.00 % 0 0 0
app/controllers/admin/email_logs_controller.rb 0.00 % 28 22 0 22 0.00 100.00 % 0 0 0
app/controllers/admin/field_groups_controller.rb 0.00 % 154 114 0 114 0.00 100.00 % 0 0 0
app/controllers/admin/fonts_controller.rb 0.00 % 147 106 0 106 0.00 100.00 % 0 0 0
app/controllers/admin/gdpr/gdpr_controller.rb 0.00 % 368 299 0 299 0.00 100.00 % 0 0 0
app/controllers/admin/gdpr_controller.rb 0.00 % 355 291 0 291 0.00 100.00 % 0 0 0
app/controllers/admin/geolocation_settings_controller.rb 0.00 % 187 144 0 144 0.00 100.00 % 0 0 0
app/controllers/admin/image_optimization_analytics_controller.rb 0.00 % 118 91 0 91 0.00 100.00 % 0 0 0
app/controllers/admin/integrations_controller.rb 0.00 % 162 131 0 131 0.00 100.00 % 0 0 0
app/controllers/admin/logs_controller.rb 0.00 % 160 111 0 111 0.00 100.00 % 0 0 0
app/controllers/admin/mcp_settings_controller.rb 0.00 % 368 345 0 345 0.00 100.00 % 0 0 0
app/controllers/admin/media_controller.rb 0.00 % 141 109 0 109 0.00 100.00 % 0 0 0
app/controllers/admin/menus_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/admin/oauth_controller.rb 0.00 % 382 307 0 307 0.00 100.00 % 0 0 0
app/controllers/admin/page_templates_controller.rb 0.00 % 140 101 0 101 0.00 100.00 % 0 0 0
app/controllers/admin/pages_controller.rb 0.00 % 212 178 0 178 0.00 100.00 % 0 0 0
app/controllers/admin/passwords_controller.rb 0.00 % 9 7 0 7 0.00 100.00 % 0 0 0
app/controllers/admin/pixels_controller.rb 0.00 % 122 82 0 82 0.00 100.00 % 0 0 0
app/controllers/admin/plugin_pages_controller.rb 0.00 % 78 55 0 55 0.00 100.00 % 0 0 0
app/controllers/admin/plugins_controller.rb 0.00 % 611 491 0 491 0.00 100.00 % 0 0 0
app/controllers/admin/posts_controller.rb 0.00 % 390 320 0 320 0.00 100.00 % 0 0 0
app/controllers/admin/profile_controller.rb 0.00 % 59 42 0 42 0.00 100.00 % 0 0 0
app/controllers/admin/redirects_controller.rb 0.00 % 169 119 0 119 0.00 100.00 % 0 0 0
app/controllers/admin/security_controller.rb 0.00 % 71 46 0 46 0.00 100.00 % 0 0 0
app/controllers/admin/sessions_controller.rb 0.00 % 26 17 0 17 0.00 100.00 % 0 0 0
app/controllers/admin/settings/storage_controller.rb 0.00 % 19 14 0 14 0.00 100.00 % 0 0 0
app/controllers/admin/settings/upload_security_controller.rb 0.00 % 36 28 0 28 0.00 100.00 % 0 0 0
app/controllers/admin/settings_controller.rb 0.00 % 555 420 0 420 0.00 100.00 % 0 0 0
app/controllers/admin/shortcodes_controller.rb 0.00 % 115 100 0 100 0.00 100.00 % 0 0 0
app/controllers/admin/site_settings_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms/forms_controller.rb 0.00 % 165 130 0 130 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms/submissions_controller.rb 0.00 % 205 161 0 161 0.00 100.00 % 0 0 0
app/controllers/admin/slick_forms_controller.rb 0.00 % 667 558 0 558 0.00 100.00 % 0 0 0
app/controllers/admin/storage_providers_controller.rb 0.00 % 98 75 0 75 0.00 100.00 % 0 0 0
app/controllers/admin/subscribers_controller.rb 0.00 % 205 144 0 144 0.00 100.00 % 0 0 0
app/controllers/admin/system/api_tokens_controller.rb 0.00 % 81 60 0 60 0.00 100.00 % 0 0 0
app/controllers/admin/system/channel_overrides_controller.rb 0.00 % 129 107 0 107 0.00 100.00 % 0 0 0
app/controllers/admin/system/channels_controller.rb 0.00 % 178 165 0 165 0.00 100.00 % 0 0 0
app/controllers/admin/system/headless_controller.rb 0.00 % 42 32 0 32 0.00 100.00 % 0 0 0
app/controllers/admin/tags_controller.rb 0.00 % 100 77 0 77 0.00 100.00 % 0 0 0
app/controllers/admin/taxonomies_controller.rb 0.00 % 58 40 0 40 0.00 100.00 % 0 0 0
app/controllers/admin/template_customizer_controller.rb 0.00 % 384 304 0 304 0.00 100.00 % 0 0 0
app/controllers/admin/terms_controller.rb 0.00 % 55 40 0 40 0.00 100.00 % 0 0 0
app/controllers/admin/theme_editor_controller.rb 0.00 % 207 169 0 169 0.00 100.00 % 0 0 0
app/controllers/admin/themes_controller.rb 0.00 % 248 179 0 179 0.00 100.00 % 0 0 0
app/controllers/admin/tools/erase_personal_data_controller.rb 0.00 % 91 63 0 63 0.00 100.00 % 0 0 0
app/controllers/admin/tools/export_controller.rb 0.00 % 67 46 0 46 0.00 100.00 % 0 0 0
app/controllers/admin/tools/export_personal_data_controller.rb 0.00 % 77 52 0 52 0.00 100.00 % 0 0 0
app/controllers/admin/tools/import_controller.rb 0.00 % 79 53 0 53 0.00 100.00 % 0 0 0
app/controllers/admin/tools/shortcuts_controller.rb 0.00 % 96 69 0 69 0.00 100.00 % 0 0 0
app/controllers/admin/tools/site_health_controller.rb 0.00 % 245 185 0 185 0.00 100.00 % 0 0 0
app/controllers/admin/trash_controller.rb 0.00 % 51 41 0 41 0.00 100.00 % 0 0 0
app/controllers/admin/trash_settings_controller.rb 0.00 % 58 45 0 45 0.00 100.00 % 0 0 0
app/controllers/admin/updates_controller.rb 0.00 % 38 24 0 24 0.00 100.00 % 0 0 0
app/controllers/admin/user_preferences_controller.rb 0.00 % 16 15 0 15 0.00 100.00 % 0 0 0
app/controllers/admin/users_controller.rb 0.00 % 350 276 0 276 0.00 100.00 % 0 0 0
app/controllers/admin/webhooks_controller.rb 0.00 % 247 199 0 199 0.00 100.00 % 0 0 0
app/controllers/admin/widgets_controller.rb 0.00 % 70 50 0 50 0.00 100.00 % 0 0 0
app/controllers/analytics_controller.rb 0.00 % 107 77 0 77 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_agents_controller.rb 0.00 % 202 170 0 170 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_providers_controller.rb 0.00 % 164 135 0 135 0.00 100.00 % 0 0 0
app/controllers/api/v1/ai_seo_controller.rb 0.00 % 108 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/analytics_controller.rb 0.00 % 222 190 0 190 0.00 100.00 % 0 0 0
app/controllers/api/v1/auth_controller.rb 0.00 % 98 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/base_controller.rb 0.00 % 40 27 0 27 0.00 100.00 % 0 0 0
app/controllers/api/v1/categories_controller.rb 0.00 % 106 77 0 77 0.00 100.00 % 0 0 0
app/controllers/api/v1/channels_controller.rb 0.00 % 117 96 0 96 0.00 100.00 % 0 0 0
app/controllers/api/v1/comments_controller.rb 0.00 % 223 165 0 165 0.00 100.00 % 0 0 0
app/controllers/api/v1/consent_controller.rb 0.00 % 281 227 0 227 0.00 100.00 % 0 0 0
app/controllers/api/v1/content_types_controller.rb 0.00 % 64 50 0 50 0.00 100.00 % 0 0 0
app/controllers/api/v1/docs_controller.rb 0.00 % 143 132 0 132 0.00 100.00 % 0 0 0
app/controllers/api/v1/gdpr_controller.rb 0.00 % 326 273 0 273 0.00 100.00 % 0 0 0
app/controllers/api/v1/image_optimization_controller.rb 0.00 % 238 196 0 196 0.00 100.00 % 0 0 0
app/controllers/api/v1/mcp_controller.rb 0.00 % 1934 1728 0 1728 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller.rb 0.00 % 161 111 0 111 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller_new.rb 0.00 % 122 81 0 81 0.00 100.00 % 0 0 0
app/controllers/api/v1/media_controller_old.rb 0.00 % 241 188 0 188 0.00 100.00 % 0 0 0
app/controllers/api/v1/menus_controller.rb 0.00 % 112 79 0 79 0.00 100.00 % 0 0 0
app/controllers/api/v1/meta_fields_controller.rb 0.00 % 239 206 0 206 0.00 100.00 % 0 0 0
app/controllers/api/v1/openai_controller.rb 0.00 % 265 219 0 219 0.00 100.00 % 0 0 0
app/controllers/api/v1/pages_controller.rb 0.00 % 182 126 0 126 0.00 100.00 % 0 0 0
app/controllers/api/v1/posts_controller.rb 0.00 % 202 142 0 142 0.00 100.00 % 0 0 0
app/controllers/api/v1/settings_controller.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/controllers/api/v1/simple_controller.rb 0.00 % 10 9 0 9 0.00 100.00 % 0 0 0
app/controllers/api/v1/subscribers_controller.rb 0.00 % 185 129 0 129 0.00 100.00 % 0 0 0
app/controllers/api/v1/system_controller.rb 0.00 % 82 74 0 74 0.00 100.00 % 0 0 0
app/controllers/api/v1/tags_controller.rb 0.00 % 78 61 0 61 0.00 100.00 % 0 0 0
app/controllers/api/v1/taxonomies_controller.rb 0.00 % 139 98 0 98 0.00 100.00 % 0 0 0
app/controllers/api/v1/terms_controller.rb 0.00 % 122 87 0 87 0.00 100.00 % 0 0 0
app/controllers/api/v1/test_controller.rb 0.00 % 36 31 0 31 0.00 100.00 % 0 0 0
app/controllers/api/v1/themes_controller.rb 0.00 % 110 91 0 91 0.00 100.00 % 0 0 0
app/controllers/api/v1/uploads_controller.rb 0.00 % 212 172 0 172 0.00 100.00 % 0 0 0
app/controllers/application_controller.rb 0.00 % 61 39 0 39 0.00 100.00 % 0 0 0
app/controllers/comments_controller.rb 0.00 % 34 24 0 24 0.00 100.00 % 0 0 0
app/controllers/concerns/liquid_renderable.rb 0.00 % 59 43 0 43 0.00 100.00 % 0 0 0
app/controllers/concerns/themeable.rb 0.00 % 45 30 0 30 0.00 100.00 % 0 0 0
app/controllers/csp_reports_controller.rb 0.00 % 31 16 0 16 0.00 100.00 % 0 0 0
app/controllers/feeds_controller.rb 0.00 % 101 75 0 75 0.00 100.00 % 0 0 0
app/controllers/gdpr_controller.rb 0.00 % 252 189 0 189 0.00 100.00 % 0 0 0
app/controllers/graphql_controller.rb 0.00 % 52 38 0 38 0.00 100.00 % 0 0 0
app/controllers/home_controller.rb 0.00 % 115 84 0 84 0.00 100.00 % 0 0 0
app/controllers/omniauth_callbacks_controller.rb 0.00 % 151 109 0 109 0.00 100.00 % 0 0 0
app/controllers/pages_controller.rb 0.00 % 85 57 0 57 0.00 100.00 % 0 0 0
app/controllers/plugins/slick_forms/forms_controller.rb 0.00 % 39 24 0 24 0.00 100.00 % 0 0 0
app/controllers/plugins/slick_forms/submissions_controller.rb 0.00 % 105 69 0 69 0.00 100.00 % 0 0 0
app/controllers/posts_controller.rb 0.00 % 230 185 0 185 0.00 100.00 % 0 0 0
app/controllers/preview_controller.rb 0.00 % 48 37 0 37 0.00 100.00 % 0 0 0
app/controllers/slick_forms_controller.rb 0.00 % 332 248 0 248 0.00 100.00 % 0 0 0
app/controllers/subscribers_controller.rb 0.00 % 77 54 0 54 0.00 100.00 % 0 0 0
app/controllers/theme_assets_controller.rb 0.00 % 37 24 0 24 0.00 100.00 % 0 0 0
app/controllers/themes_controller.rb 0.00 % 79 57 0 57 0.00 100.00 % 0 0 0
app/controllers/users/confirmations_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/omniauth_callbacks_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/passwords_controller.rb 0.00 % 34 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/registrations_controller.rb 0.00 % 62 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/sessions_controller.rb 0.00 % 27 2 0 2 0.00 100.00 % 0 0 0
app/controllers/users/unlocks_controller.rb 0.00 % 30 2 0 2 0.00 100.00 % 0 0 0

Channels ( 0.0% covered at 0.0 hits/line )

4 files in total.
68 relevant lines, 0 lines covered and 68 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/channels/application_cable/channel.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/channels/application_cable/connection.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/channels/builder_preview_channel.rb 0.00 % 41 29 0 29 0.00 100.00 % 0 0 0
app/channels/realtime_analytics_channel.rb 0.00 % 39 31 0 31 0.00 100.00 % 0 0 0

Models ( 2.36% covered at 0.03 hits/line )

85 files in total.
7278 relevant lines, 172 lines covered and 7106 lines missed. ( 2.36% )
82 total branches, 2 branches covered and 80 branches missed. ( 2.44% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/models/admin_notification.rb 0.00 % 2 2 0 2 0.00 100.00 % 0 0 0
app/models/ai_agent.rb 0.00 % 166 117 0 117 0.00 100.00 % 0 0 0
app/models/ai_provider.rb 0.00 % 46 38 0 38 0.00 100.00 % 0 0 0
app/models/ai_usage.rb 0.00 % 35 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_audit_log.rb 0.00 % 39 30 0 30 0.00 100.00 % 0 0 0
app/models/analytics_consent.rb 0.00 % 37 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_data_deletion.rb 0.00 % 35 28 0 28 0.00 100.00 % 0 0 0
app/models/analytics_event.rb 0.00 % 71 55 0 55 0.00 100.00 % 0 0 0
app/models/api_token.rb 0.00 % 92 71 0 71 0.00 100.00 % 0 0 0
app/models/application_record.rb 100.00 % 3 2 2 0 1.00 100.00 % 0 0 0
app/models/archived_analytics_event.rb 0.00 % 49 37 0 37 0.00 100.00 % 0 0 0
app/models/archived_pageview.rb 0.00 % 59 47 0 47 0.00 100.00 % 0 0 0
app/models/builder_page.rb 0.00 % 159 123 0 123 0.00 100.00 % 0 0 0
app/models/builder_page_section.rb 0.00 % 135 110 0 110 0.00 100.00 % 0 0 0
app/models/builder_theme.rb 0.00 % 644 460 0 460 0.00 100.00 % 0 0 0
app/models/builder_theme_file.rb 0.00 % 119 93 0 93 0.00 100.00 % 0 0 0
app/models/builder_theme_section.rb 0.00 % 123 101 0 101 0.00 100.00 % 0 0 0
app/models/builder_theme_snapshot.rb 0.00 % 159 110 0 110 0.00 100.00 % 0 0 0
app/models/channel.rb 0.00 % 118 87 0 87 0.00 100.00 % 0 0 0
app/models/channel_override.rb 0.00 % 67 49 0 49 0.00 100.00 % 0 0 0
app/models/comment.rb 0.00 % 154 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/channel_detection.rb 0.00 % 162 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/has_taxonomies.rb 0.00 % 111 71 0 71 0.00 100.00 % 0 0 0
app/models/concerns/metable.rb 40.30 % 132 67 27 40 0.40 0.00 % 15 0 15
app/models/concerns/railspress/channel_detection.rb 0.00 % 161 119 0 119 0.00 100.00 % 0 0 0
app/models/concerns/sanitizable.rb 0.00 % 48 24 0 24 0.00 100.00 % 0 0 0
app/models/concerns/seo_optimizable.rb 0.00 % 135 89 0 89 0.00 100.00 % 0 0 0
app/models/concerns/trashable.rb 0.00 % 69 47 0 47 0.00 100.00 % 0 0 0
app/models/consent_configuration.rb 0.00 % 612 508 0 508 0.00 100.00 % 0 0 0
app/models/content_type.rb 0.00 % 84 59 0 59 0.00 100.00 % 0 0 0
app/models/current.rb 0.00 % 7 3 0 3 0.00 100.00 % 0 0 0
app/models/custom_field.rb 0.00 % 134 99 0 99 0.00 100.00 % 0 0 0
app/models/custom_field_value.rb 0.00 % 64 49 0 49 0.00 100.00 % 0 0 0
app/models/custom_font.rb 0.00 % 160 112 0 112 0.00 100.00 % 0 0 0
app/models/email_log.rb 0.00 % 72 55 0 55 0.00 100.00 % 0 0 0
app/models/export_job.rb 0.00 % 18 14 0 14 0.00 100.00 % 0 0 0
app/models/field_group.rb 0.00 % 165 125 0 125 0.00 100.00 % 0 0 0
app/models/image_optimization_log.rb 0.00 % 293 231 0 231 0.00 100.00 % 0 0 0
app/models/import_job.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/models/medium.rb 0.00 % 154 111 0 111 0.00 100.00 % 0 0 0
app/models/menu.rb 0.00 % 19 10 0 10 0.00 100.00 % 0 0 0
app/models/menu_item.rb 0.00 % 29 17 0 17 0.00 100.00 % 0 0 0
app/models/meta_field.rb 0.00 % 144 97 0 97 0.00 100.00 % 0 0 0
app/models/oauth_account.rb 0.00 % 100 78 0 78 0.00 100.00 % 0 0 0
app/models/page.rb 0.00 % 230 155 0 155 0.00 100.00 % 0 0 0
app/models/page_template.rb 0.00 % 178 144 0 144 0.00 100.00 % 0 0 0
app/models/pageview.rb 0.00 % 427 320 0 320 0.00 100.00 % 0 0 0
app/models/personal_data_erasure_request.rb 0.00 % 28 21 0 21 0.00 100.00 % 0 0 0
app/models/personal_data_export_request.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/models/pixel.rb 0.00 % 367 324 0 324 0.00 100.00 % 0 0 0
app/models/plugin.rb 71.43 % 30 14 10 4 0.79 0.00 % 2 0 2
app/models/plugin_setting.rb 0.00 % 64 49 0 49 0.00 100.00 % 0 0 0
app/models/post.rb 0.00 % 338 232 0 232 0.00 100.00 % 0 0 0
app/models/published_theme_file.rb 0.00 % 10 8 0 8 0.00 100.00 % 0 0 0
app/models/published_theme_version.rb 0.00 % 62 45 0 45 0.00 100.00 % 0 0 0
app/models/redirect.rb 0.00 % 196 140 0 140 0.00 100.00 % 0 0 0
app/models/shortcut.rb 0.00 % 39 28 0 28 0.00 100.00 % 0 0 0
app/models/site_setting.rb 54.55 % 45 22 12 10 1.27 16.67 % 12 2 10
app/models/slick_form.rb 0.00 % 68 45 0 45 0.00 100.00 % 0 0 0
app/models/slick_form_submission.rb 0.00 % 119 88 0 88 0.00 100.00 % 0 0 0
app/models/storage_provider.rb 0.00 % 109 86 0 86 0.00 100.00 % 0 0 0
app/models/subscriber.rb 0.00 % 232 165 0 165 0.00 100.00 % 0 0 0
app/models/taxonomy.rb 0.00 % 72 49 0 49 0.00 100.00 % 0 0 0
app/models/template.rb 0.00 % 70 55 0 55 0.00 100.00 % 0 0 0
app/models/tenant.rb 54.43 % 149 79 43 36 0.54 0.00 % 16 0 16
app/models/term.rb 0.00 % 104 70 0 70 0.00 100.00 % 0 0 0
app/models/term_relationship.rb 0.00 % 18 13 0 13 0.00 100.00 % 0 0 0
app/models/theme.rb 41.54 % 142 65 27 38 0.43 0.00 % 20 0 20
app/models/theme_file.rb 0.00 % 100 79 0 79 0.00 100.00 % 0 0 0
app/models/theme_file_version.rb 0.00 % 55 43 0 43 0.00 100.00 % 0 0 0
app/models/theme_preview.rb 0.00 % 135 94 0 94 0.00 100.00 % 0 0 0
app/models/theme_preview_block.rb 0.00 % 22 14 0 14 0.00 100.00 % 0 0 0
app/models/theme_preview_file.rb 0.00 % 86 65 0 65 0.00 100.00 % 0 0 0
app/models/theme_preview_section.rb 0.00 % 101 68 0 68 0.00 100.00 % 0 0 0
app/models/theme_version.rb 0.00 % 143 103 0 103 0.00 100.00 % 0 0 0
app/models/theme_version_file.rb 0.00 % 80 62 0 62 0.00 100.00 % 0 0 0
app/models/trash_setting.rb 0.00 % 48 33 0 33 0.00 100.00 % 0 0 0
app/models/upload.rb 0.00 % 231 157 0 157 0.00 100.00 % 0 0 0
app/models/upload_security.rb 0.00 % 215 156 0 156 0.00 100.00 % 0 0 0
app/models/user.rb 51.00 % 212 100 51 49 0.51 0.00 % 17 0 17
app/models/user_consent.rb 0.00 % 59 45 0 45 0.00 100.00 % 0 0 0
app/models/user_notification.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/webhook.rb 0.00 % 107 73 0 73 0.00 100.00 % 0 0 0
app/models/webhook_delivery.rb 0.00 % 94 63 0 63 0.00 100.00 % 0 0 0
app/models/widget.rb 0.00 % 43 29 0 29 0.00 100.00 % 0 0 0

Mailers ( 4.55% covered at 0.05 hits/line )

4 files in total.
66 relevant lines, 3 lines covered and 63 lines missed. ( 4.55% )
12 total branches, 0 branches covered and 12 branches missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/mailers/application_mailer.rb 0.00 % 4 4 0 4 0.00 100.00 % 0 0 0
app/mailers/email_logging_interceptor.rb 23.08 % 41 13 3 10 0.23 0.00 % 12 0 12
app/mailers/slick_forms_mailer.rb 0.00 % 50 37 0 37 0.00 100.00 % 0 0 0
app/mailers/test_mailer.rb 0.00 % 22 12 0 12 0.00 100.00 % 0 0 0

Helpers ( 19.81% covered at 0.2 hits/line )

41 files in total.
969 relevant lines, 192 lines covered and 777 lines missed. ( 19.81% )
535 total branches, 0 branches covered and 535 branches missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/helpers/admin/ai_agents_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/ai_helper.rb 35.29 % 45 17 6 11 0.35 100.00 % 0 0 0
app/helpers/admin/ai_providers_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/categories_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/comments_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/dashboard_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/media_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/menus_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/pages_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/plugins_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/posts_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/settings/redis_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/site_settings_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/tags_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/taxonomies_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/template_customizer_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/terms_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/themes_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin/webhooks_helper.rb 50.00 % 24 4 2 2 0.50 100.00 % 0 0 0
app/helpers/admin/widgets_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/admin_assets_helper.rb 50.00 % 77 12 6 6 0.50 100.00 % 0 0 0
app/helpers/ai_text_generator_helper.rb 21.88 % 105 32 7 25 0.22 0.00 % 4 0 4
app/helpers/analytics_helper.rb 5.46 % 572 348 19 329 0.05 0.00 % 279 0 279
app/helpers/api/v1/ai_agents_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/api/v1/docs_helper.rb 40.00 % 28 10 4 6 0.40 0.00 % 5 0 5
app/helpers/appearance_helper.rb 24.49 % 307 49 12 37 0.24 0.00 % 18 0 18
app/helpers/application_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/consent_helper.rb 24.72 % 263 89 22 67 0.25 0.00 % 58 0 58
app/helpers/editor_helper.rb 25.00 % 77 24 6 18 0.25 0.00 % 16 0 16
app/helpers/home_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/image_optimization_helper.rb 21.62 % 113 37 8 29 0.22 0.00 % 21 0 21
app/helpers/monaco_helper.rb 33.33 % 74 18 6 12 0.33 0.00 % 7 0 7
app/helpers/pages_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/pixels_helper.rb 23.53 % 49 17 4 13 0.24 0.00 % 6 0 6
app/helpers/plugin_blocks_helper.rb 22.22 % 81 18 4 14 0.22 0.00 % 6 0 6
app/helpers/plugin_settings_helper.rb 17.33 % 182 75 13 62 0.17 0.00 % 29 0 29
app/helpers/posts_helper.rb 100.00 % 2 1 1 0 1.00 100.00 % 0 0 0
app/helpers/seo_helper.rb 11.11 % 74 36 4 32 0.11 0.00 % 18 0 18
app/helpers/status_helper.rb 36.36 % 112 22 8 14 0.36 100.00 % 0 0 0
app/helpers/taxonomy_helper.rb 27.50 % 190 80 22 58 0.28 0.00 % 42 0 42
app/helpers/toggle_switch_helper.rb 27.59 % 114 58 16 42 0.28 0.00 % 26 0 26

Jobs ( 0.0% covered at 0.0 hits/line )

14 files in total.
890 relevant lines, 0 lines covered and 890 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/jobs/advanced_analytics_processing_job.rb 0.00 % 413 312 0 312 0.00 100.00 % 0 0 0
app/jobs/analytics_archive_job.rb 0.00 % 23 15 0 15 0.00 100.00 % 0 0 0
app/jobs/analytics_processing_job.rb 0.00 % 34 25 0 25 0.00 100.00 % 0 0 0
app/jobs/analytics_retention_job.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/jobs/application_job.rb 0.00 % 7 2 0 2 0.00 100.00 % 0 0 0
app/jobs/check_updates_job.rb 0.00 % 37 16 0 16 0.00 100.00 % 0 0 0
app/jobs/content_analytics_update_job.rb 0.00 % 88 52 0 52 0.00 100.00 % 0 0 0
app/jobs/deliver_webhook_job.rb 0.00 % 70 42 0 42 0.00 100.00 % 0 0 0
app/jobs/maxmind_update_job.rb 0.00 % 59 37 0 37 0.00 100.00 % 0 0 0
app/jobs/optimize_image_job.rb 0.00 % 31 23 0 23 0.00 100.00 % 0 0 0
app/jobs/plugin_task_worker_job.rb 0.00 % 33 25 0 25 0.00 100.00 % 0 0 0
app/jobs/slick_forms_integration_job.rb 0.00 % 246 186 0 186 0.00 100.00 % 0 0 0
app/jobs/slick_forms_notification_job.rb 0.00 % 135 102 0 102 0.00 100.00 % 0 0 0
app/jobs/webhook_job.rb 0.00 % 61 44 0 44 0.00 100.00 % 0 0 0

Libraries ( 6.22% covered at 0.07 hits/line )

33 files in total.
5804 relevant lines, 361 lines covered and 5443 lines missed. ( 6.22% )
403 total branches, 9 branches covered and 394 branches missed. ( 2.23% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/development_plugin_watcher.rb 0.00 % 61 40 0 40 0.00 100.00 % 0 0 0
lib/generators/plugin_generator.rb 0.00 % 921 635 0 635 0.00 100.00 % 0 0 0
lib/plugins/PLUGIN_TEMPLATE.rb 0.00 % 161 66 0 66 0.00 100.00 % 0 0 0
lib/plugins/advanced_shortcodes/advanced_shortcodes.rb 0.00 % 243 193 0 193 0.00 100.00 % 0 0 0
lib/plugins/ai_seo/ai_seo.rb 0.00 % 559 430 0 430 0.00 100.00 % 0 0 0
lib/plugins/email_notifications/email_notifications.rb 0.00 % 173 136 0 136 0.00 100.00 % 0 0 0
lib/plugins/hello_tupac/hello_tupac.rb 0.00 % 86 69 0 69 0.00 100.00 % 0 0 0
lib/plugins/image_optimizer/image_optimizer.rb 0.00 % 48 24 0 24 0.00 100.00 % 0 0 0
lib/plugins/reading_time/reading_time.rb 0.00 % 70 42 0 42 0.00 100.00 % 0 0 0
lib/plugins/related_posts/related_posts.rb 0.00 % 127 90 0 90 0.00 100.00 % 0 0 0
lib/plugins/seo_optimizer_pro/seo_optimizer_pro.rb 0.00 % 74 39 0 39 0.00 100.00 % 0 0 0
lib/plugins/sitemap_generator/sitemap_generator.rb 0.00 % 93 66 0 66 0.00 100.00 % 0 0 0
lib/plugins/slick_forms/slick_forms.rb 0.00 % 626 471 0 471 0.00 100.00 % 0 0 0
lib/plugins/slick_forms_pro/slick_forms_pro.rb 0.00 % 538 397 0 397 0.00 100.00 % 0 0 0
lib/plugins/social_sharing/social_sharing.rb 0.00 % 197 149 0 149 0.00 100.00 % 0 0 0
lib/plugins/spam_protection/spam_protection.rb 0.00 % 118 70 0 70 0.00 100.00 % 0 0 0
lib/plugins/uploadcare/uploadcare.rb 0.00 % 325 244 0 244 0.00 100.00 % 0 0 0
lib/railspress/ai_agent_integration/channels.rb 0.00 % 184 131 0 131 0.00 100.00 % 0 0 0
lib/railspress/ai_agent_plugin_helper.rb 0.00 % 143 81 0 81 0.00 100.00 % 0 0 0
lib/railspress/html_sanitizer.rb 0.00 % 249 162 0 162 0.00 100.00 % 0 0 0
lib/railspress/liquid/consent_tags.rb 33.03 % 664 218 72 146 0.38 0.00 % 82 0 82
lib/railspress/liquid/image_optimization_tags.rb 33.33 % 373 99 33 66 0.33 0.00 % 26 0 26
lib/railspress/newsletter_shortcodes.rb 0.00 % 372 319 0 319 0.00 100.00 % 0 0 0
lib/railspress/plugin_api/channels.rb 0.00 % 128 91 0 91 0.00 100.00 % 0 0 0
lib/railspress/plugin_base.rb 27.55 % 1188 432 119 313 0.28 0.00 % 105 0 105
lib/railspress/plugin_blocks.rb 0.00 % 172 96 0 96 0.00 100.00 % 0 0 0
lib/railspress/plugin_jobs.rb 0.00 % 161 84 0 84 0.00 100.00 % 0 0 0
lib/railspress/plugin_system.rb 26.69 % 638 281 75 206 0.27 6.00 % 100 6 94
lib/railspress/settings_schema.rb 0.00 % 267 192 0 192 0.00 100.00 % 0 0 0
lib/railspress/shortcode_processor.rb 29.27 % 277 123 36 87 0.46 0.00 % 34 0 34
lib/railspress/theme_loader.rb 22.81 % 237 114 26 88 0.23 5.36 % 56 3 53
lib/railspress/update_checker.rb 0.00 % 141 104 0 104 0.00 100.00 % 0 0 0
lib/railspress/webhook_dispatcher.rb 0.00 % 143 116 0 116 0.00 100.00 % 0 0 0

Services ( 0.17% covered at 0.0 hits/line )

32 files in total.
6516 relevant lines, 11 lines covered and 6505 lines missed. ( 0.17% )
17 total branches, 0 branches covered and 17 branches missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/services/advanced_analytics_service.rb 0.00 % 475 344 0 344 0.00 100.00 % 0 0 0
app/services/ai_helper.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/services/ai_service.rb 0.00 % 161 128 0 128 0.00 100.00 % 0 0 0
app/services/akismet_service.rb 0.00 % 97 76 0 76 0.00 100.00 % 0 0 0
app/services/analytics_archive_service.rb 0.00 % 291 235 0 235 0.00 100.00 % 0 0 0
app/services/analytics_retention_service.rb 0.00 % 89 63 0 63 0.00 100.00 % 0 0 0
app/services/analytics_security_service.rb 0.00 % 535 374 0 374 0.00 100.00 % 0 0 0
app/services/analytics_service.rb 0.00 % 503 414 0 414 0.00 100.00 % 0 0 0
app/services/builder_liquid_renderer.rb 0.00 % 964 716 0 716 0.00 100.00 % 0 0 0
app/services/builder_theme_service.rb 0.00 % 235 155 0 155 0.00 100.00 % 0 0 0
app/services/content_analytics_service.rb 0.00 % 325 239 0 239 0.00 100.00 % 0 0 0
app/services/documentation_sync_service.rb 0.00 % 72 45 0 45 0.00 100.00 % 0 0 0
app/services/frontend_renderer_service.rb 0.00 % 151 110 0 110 0.00 100.00 % 0 0 0
app/services/frontend_theme_renderer.rb 0.00 % 121 91 0 91 0.00 100.00 % 0 0 0
app/services/gdpr_compliance_service.rb 0.00 % 455 351 0 351 0.00 100.00 % 0 0 0
app/services/gdpr_service.rb 0.00 % 403 327 0 327 0.00 100.00 % 0 0 0
app/services/geolocation_service.rb 0.00 % 364 293 0 293 0.00 100.00 % 0 0 0
app/services/image_optimization_service.rb 0.00 % 573 435 0 435 0.00 100.00 % 0 0 0
app/services/liquid_template_renderer.rb 16.67 % 176 66 11 55 0.17 0.00 % 17 0 17
app/services/liquid_template_version_renderer.rb 0.00 % 184 135 0 135 0.00 100.00 % 0 0 0
app/services/maxmind_updater_service.rb 0.00 % 323 243 0 243 0.00 100.00 % 0 0 0
app/services/oauth_provider_service.rb 0.00 % 103 79 0 79 0.00 100.00 % 0 0 0
app/services/plugin_reload_service.rb 0.00 % 44 28 0 28 0.00 100.00 % 0 0 0
app/services/post_by_email_service.rb 0.00 % 228 160 0 160 0.00 100.00 % 0 0 0
app/services/realtime_analytics_service.rb 0.00 % 52 49 0 49 0.00 100.00 % 0 0 0
app/services/screenshot_service.rb 0.00 % 121 83 0 83 0.00 100.00 % 0 0 0
app/services/storage_configuration_service.rb 0.00 % 177 121 0 121 0.00 100.00 % 0 0 0
app/services/theme_file_manager.rb 0.00 % 312 218 0 218 0.00 100.00 % 0 0 0
app/services/theme_preview_renderer.rb 0.00 % 190 137 0 137 0.00 100.00 % 0 0 0
app/services/theme_version_loader.rb 0.00 % 157 122 0 122 0.00 100.00 % 0 0 0
app/services/theme_version_service.rb 0.00 % 116 87 0 87 0.00 100.00 % 0 0 0
app/services/themes_manager.rb 0.00 % 736 530 0 530 0.00 100.00 % 0 0 0

Views ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches

Ungrouped ( 1.86% covered at 0.02 hits/line )

59 files in total.
2369 relevant lines, 44 lines covered and 2325 lines missed. ( 1.86% )
82 total branches, 0 branches covered and 82 branches missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/constraints/admin_constraint.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/graphql/mutations/base_mutation.rb 0.00 % 10 8 0 8 0.00 100.00 % 0 0 0
app/graphql/mutations/gdpr_mutations.rb 0.00 % 245 199 0 199 0.00 100.00 % 0 0 0
app/graphql/mutations/meta_fields/create_meta_field.rb 0.00 % 64 51 0 51 0.00 100.00 % 0 0 0
app/graphql/railspress_schema.rb 0.00 % 45 19 0 19 0.00 100.00 % 0 0 0
app/graphql/resolvers/base_resolver.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/resolvers/channel_resolver.rb 0.00 % 21 17 0 17 0.00 100.00 % 0 0 0
app/graphql/resolvers/channels_resolver.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/graphql/resolvers/image_optimization_resolver.rb 0.00 % 179 142 0 142 0.00 100.00 % 0 0 0
app/graphql/resolvers/media_resolver.rb 0.00 % 48 33 0 33 0.00 100.00 % 0 0 0
app/graphql/resolvers/pages_resolver.rb 0.00 % 55 38 0 38 0.00 100.00 % 0 0 0
app/graphql/resolvers/posts_resolver.rb 0.00 % 57 40 0 40 0.00 100.00 % 0 0 0
app/graphql/types/ai_agent_type.rb 0.00 % 69 50 0 50 0.00 100.00 % 0 0 0
app/graphql/types/analytics_type.rb 0.00 % 126 103 0 103 0.00 100.00 % 0 0 0
app/graphql/types/base_argument.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_connection.rb 0.00 % 8 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_edge.rb 0.00 % 8 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_enum.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_field.rb 0.00 % 7 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_input_object.rb 0.00 % 7 5 0 5 0.00 100.00 % 0 0 0
app/graphql/types/base_interface.rb 0.00 % 11 8 0 8 0.00 100.00 % 0 0 0
app/graphql/types/base_object.rb 0.00 % 9 7 0 7 0.00 100.00 % 0 0 0
app/graphql/types/base_scalar.rb 0.00 % 6 4 0 4 0.00 100.00 % 0 0 0
app/graphql/types/base_union.rb 0.00 % 8 6 0 6 0.00 100.00 % 0 0 0
app/graphql/types/category_type.rb 0.00 % 31 23 0 23 0.00 100.00 % 0 0 0
app/graphql/types/channel_override_type.rb 0.00 % 48 38 0 38 0.00 100.00 % 0 0 0
app/graphql/types/channel_type.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/graphql/types/comment_type.rb 0.00 % 42 25 0 25 0.00 100.00 % 0 0 0
app/graphql/types/content_type_type.rb 0.00 % 39 30 0 30 0.00 100.00 % 0 0 0
app/graphql/types/gdpr_type.rb 0.00 % 139 113 0 113 0.00 100.00 % 0 0 0
app/graphql/types/image_optimization_log_type.rb 0.00 % 89 78 0 78 0.00 100.00 % 0 0 0
app/graphql/types/media_type.rb 0.00 % 58 40 0 40 0.00 100.00 % 0 0 0
app/graphql/types/medium_type.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/meta_field_input_type.rb 0.00 % 26 17 0 17 0.00 100.00 % 0 0 0
app/graphql/types/meta_field_type.rb 0.00 % 55 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/mutation_type.rb 0.00 % 94 64 0 64 0.00 100.00 % 0 0 0
app/graphql/types/node_type.rb 0.00 % 9 6 0 6 0.00 100.00 % 0 0 0
app/graphql/types/page_type.rb 0.00 % 46 36 0 36 0.00 100.00 % 0 0 0
app/graphql/types/post_type.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/graphql/types/query_type.rb 0.00 % 40 26 0 26 0.00 100.00 % 0 0 0
app/graphql/types/search_results_type.rb 0.00 % 17 8 0 8 0.00 100.00 % 0 0 0
app/graphql/types/storage_provider_type.rb 0.00 % 37 27 0 27 0.00 100.00 % 0 0 0
app/graphql/types/subscriber_type.rb 0.00 % 32 20 0 20 0.00 100.00 % 0 0 0
app/graphql/types/tag_type.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/graphql/types/taxonomy_type.rb 0.00 % 46 31 0 31 0.00 100.00 % 0 0 0
app/graphql/types/term_type.rb 0.00 % 51 33 0 33 0.00 100.00 % 0 0 0
app/graphql/types/upload_type.rb 0.00 % 59 41 0 41 0.00 100.00 % 0 0 0
app/graphql/types/user_type.rb 0.00 % 102 74 0 74 0.00 100.00 % 0 0 0
app/middleware/allow_iframe_for_logs.rb 33.33 % 18 9 3 6 0.33 0.00 % 2 0 2
app/middleware/analytics_tracker.rb 35.56 % 141 45 16 29 0.36 0.00 % 14 0 14
app/middleware/channel_detection_middleware.rb 28.57 % 73 35 10 25 0.29 0.00 % 16 0 16
app/middleware/headless_mode_handler.rb 26.67 % 170 30 8 22 0.27 0.00 % 22 0 22
app/middleware/redirect_handler.rb 18.92 % 107 37 7 30 0.19 0.00 % 28 0 28
app/policies/application_policy.rb 0.00 % 53 39 0 39 0.00 100.00 % 0 0 0
app/workers/export_worker.rb 0.00 % 159 133 0 133 0.00 100.00 % 0 0 0
app/workers/import_worker.rb 0.00 % 200 158 0 158 0.00 100.00 % 0 0 0
app/workers/personal_data_erasure_worker.rb 0.00 % 157 106 0 106 0.00 100.00 % 0 0 0
app/workers/personal_data_export_worker.rb 0.00 % 89 69 0 69 0.00 100.00 % 0 0 0
app/workers/post_by_email_worker.rb 0.00 % 25 13 0 13 0.00 100.00 % 0 0 0

app/channels/application_cable/channel.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module ApplicationCable
  2. class Channel < ActionCable::Channel::Base
  3. end
  4. end

app/channels/application_cable/connection.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module ApplicationCable
  2. class Connection < ActionCable::Connection::Base
  3. end
  4. end

app/channels/builder_preview_channel.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderPreviewChannel < ApplicationCable::Channel
  2. def subscribed
  3. # Subscribe to a specific builder theme's preview updates
  4. stream_from "builder_preview_#{params[:theme_id]}"
  5. end
  6. def unsubscribed
  7. # Any cleanup needed when channel is unsubscribed
  8. end
  9. def receive(data)
  10. # Handle incoming data from the client
  11. case data['type']
  12. when 'preview_ready'
  13. # Client is ready to receive updates
  14. transmit({ type: 'ack', message: 'Preview ready' })
  15. when 'request_update'
  16. # Client is requesting a fresh update
  17. broadcast_update(data['theme_id'])
  18. end
  19. end
  20. private
  21. def broadcast_update(theme_id)
  22. # Send current theme state to the preview
  23. builder_theme = BuilderTheme.find(theme_id)
  24. ActionCable.server.broadcast(
  25. "builder_preview_#{theme_id}",
  26. {
  27. type: 'theme_update',
  28. theme_id: theme_id,
  29. sections: builder_theme.sections_data,
  30. settings: builder_theme.settings_data,
  31. timestamp: Time.current.to_i
  32. }
  33. )
  34. end
  35. end

app/channels/realtime_analytics_channel.rb

0.0% lines covered

100.0% branches covered

31 relevant lines. 0 lines covered and 31 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class RealtimeAnalyticsChannel < ApplicationCable::Channel
  2. def subscribed
  3. # Subscribe to real-time analytics updates
  4. stream_from "realtime_analytics"
  5. # Send initial data
  6. send_realtime_data
  7. end
  8. def unsubscribed
  9. # Any cleanup needed when channel is unsubscribed
  10. end
  11. private
  12. def send_realtime_data
  13. data = {
  14. active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  15. current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  16. unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
  17. active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name),
  18. recent_views: Pageview.where(created_at: 10.minutes.ago..Time.current)
  19. .order(created_at: :desc)
  20. .limit(10)
  21. .map do |pv|
  22. {
  23. path: pv.path,
  24. country: pv.country_name,
  25. browser: pv.browser,
  26. device: pv.device,
  27. created_at: pv.created_at.iso8601
  28. }
  29. end,
  30. timestamp: Time.current.iso8601
  31. }
  32. transmit(data)
  33. end
  34. end

app/constraints/admin_constraint.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AdminConstraint
  2. def matches?(request)
  3. # Get session data
  4. session = request.session
  5. # Check if admin user ID exists in session
  6. admin_user_id = session[:admin_user_id]
  7. return false unless admin_user_id
  8. # Verify user exists and is active
  9. begin
  10. user = User.find_by(id: admin_user_id, is_active: true)
  11. return false unless user
  12. # Check if user has admin access
  13. user.root? || user.account_access_level&.can_manage_account?
  14. rescue => e
  15. Rails.logger.error "Admin constraint error: #{e.message}"
  16. false
  17. end
  18. end
  19. end

app/controllers/admin/access_levels_controller.rb

0.0% lines covered

100.0% branches covered

119 relevant lines. 0 lines covered and 119 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::AccessLevelsController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/access_levels
  4. def index
  5. @roles = load_roles_with_permissions
  6. end
  7. # PATCH /admin/access_levels/update_permissions
  8. def update_permissions
  9. # This would update role permissions if using a more complex permission system
  10. # For now, we'll just show what permissions each role has
  11. redirect_to admin_access_levels_path, notice: 'Role permissions are defined in the User model.'
  12. end
  13. private
  14. def ensure_admin
  15. unless current_user&.administrator?
  16. redirect_to admin_root_path, alert: 'Access denied. Administrator privileges required.'
  17. end
  18. end
  19. def load_roles_with_permissions
  20. [
  21. {
  22. name: 'Administrator',
  23. key: 'administrator',
  24. description: 'Full access to all features and settings',
  25. color: 'red',
  26. user_count: User.administrator.count,
  27. permissions: [
  28. { name: 'Full Admin Access', granted: true, description: 'Complete control over the site' },
  29. { name: 'Manage Users', granted: true, description: 'Create, edit, and delete users' },
  30. { name: 'Manage Plugins', granted: true, description: 'Activate and configure plugins' },
  31. { name: 'Manage Themes', granted: true, description: 'Change and customize themes' },
  32. { name: 'Manage Settings', granted: true, description: 'Configure all site settings' },
  33. { name: 'Publish Posts', granted: true, description: 'Publish any post or page' },
  34. { name: 'Edit Others Posts', granted: true, description: 'Edit content from other users' },
  35. { name: 'Delete Posts', granted: true, description: 'Delete any post or page' },
  36. { name: 'Moderate Comments', granted: true, description: 'Approve, edit, delete comments' },
  37. { name: 'Upload Files', granted: true, description: 'Upload to media library' },
  38. { name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
  39. ]
  40. },
  41. {
  42. name: 'Editor',
  43. key: 'editor',
  44. description: 'Can publish and manage posts including those of other users',
  45. color: 'blue',
  46. user_count: User.editor.count,
  47. permissions: [
  48. { name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
  49. { name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
  50. { name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
  51. { name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
  52. { name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
  53. { name: 'Publish Posts', granted: true, description: 'Publish any post or page' },
  54. { name: 'Edit Others Posts', granted: true, description: 'Edit content from other users' },
  55. { name: 'Delete Posts', granted: true, description: 'Delete any post or page' },
  56. { name: 'Moderate Comments', granted: true, description: 'Approve, edit, delete comments' },
  57. { name: 'Upload Files', granted: true, description: 'Upload to media library' },
  58. { name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
  59. ]
  60. },
  61. {
  62. name: 'Author',
  63. key: 'author',
  64. description: 'Can publish and manage their own posts',
  65. color: 'green',
  66. user_count: User.author.count,
  67. permissions: [
  68. { name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
  69. { name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
  70. { name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
  71. { name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
  72. { name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
  73. { name: 'Publish Posts', granted: true, description: 'Publish their own posts and pages' },
  74. { name: 'Edit Others Posts', granted: false, description: 'Edit content from other users' },
  75. { name: 'Delete Posts', granted: false, description: 'Delete only their own content' },
  76. { name: 'Moderate Comments', granted: false, description: 'Limited comment moderation' },
  77. { name: 'Upload Files', granted: true, description: 'Upload to media library' },
  78. { name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
  79. ]
  80. },
  81. {
  82. name: 'Contributor',
  83. key: 'contributor',
  84. description: 'Can write and manage their own posts but cannot publish',
  85. color: 'yellow',
  86. user_count: User.contributor.count,
  87. permissions: [
  88. { name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
  89. { name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
  90. { name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
  91. { name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
  92. { name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
  93. { name: 'Publish Posts', granted: false, description: 'Can only submit for review' },
  94. { name: 'Edit Others Posts', granted: false, description: 'Edit content from other users' },
  95. { name: 'Delete Posts', granted: false, description: 'Cannot delete posts' },
  96. { name: 'Moderate Comments', granted: false, description: 'Cannot moderate comments' },
  97. { name: 'Upload Files', granted: false, description: 'Cannot upload files' },
  98. { name: 'API Access', granted: true, description: 'Limited API access' }
  99. ]
  100. },
  101. {
  102. name: 'Subscriber',
  103. key: 'subscriber',
  104. description: 'Can only manage their profile',
  105. color: 'gray',
  106. user_count: User.subscriber.count,
  107. permissions: [
  108. { name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
  109. { name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
  110. { name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
  111. { name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
  112. { name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
  113. { name: 'Publish Posts', granted: false, description: 'Cannot create or publish posts' },
  114. { name: 'Edit Others Posts', granted: false, description: 'Cannot edit any posts' },
  115. { name: 'Delete Posts', granted: false, description: 'Cannot delete posts' },
  116. { name: 'Moderate Comments', granted: false, description: 'Cannot moderate comments' },
  117. { name: 'Upload Files', granted: false, description: 'Cannot upload files' },
  118. { name: 'API Access', granted: false, description: 'No API access' }
  119. ]
  120. }
  121. ]
  122. end
  123. end

app/controllers/admin/ai_agents_controller.rb

0.0% lines covered

100.0% branches covered

100 relevant lines. 0 lines covered and 100 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::AiAgentsController < Admin::BaseController
  2. before_action :set_ai_agent, only: [:show, :edit, :update, :destroy, :toggle, :test]
  3. # GET /admin/ai_agents
  4. def index
  5. @ai_agents = AiAgent.includes(:ai_provider).ordered
  6. end
  7. # GET /admin/ai_agents/usage
  8. def usage
  9. @ai_agents = AiAgent.includes(:ai_provider).ordered
  10. # Calculate usage statistics for all agents
  11. @total_usage = calculate_total_usage
  12. # Calculate usage statistics for each agent
  13. @agent_usage = @ai_agents.map do |agent|
  14. {
  15. agent: agent,
  16. usage_stats: calculate_agent_usage(agent)
  17. }
  18. end
  19. end
  20. # GET /admin/ai_agents/:id
  21. def show
  22. end
  23. # GET /admin/ai_agents/new
  24. def new
  25. @ai_agent = AiAgent.new
  26. @ai_providers = AiProvider.active.ordered
  27. end
  28. # POST /admin/ai_agents
  29. def create
  30. @ai_agent = AiAgent.new(ai_agent_params)
  31. @ai_providers = AiProvider.active.ordered
  32. if @ai_agent.save
  33. redirect_to admin_ai_agents_path, notice: 'AI Agent created successfully.'
  34. else
  35. render :new, status: :unprocessable_content
  36. end
  37. end
  38. # GET /admin/ai_agents/:id/edit
  39. def edit
  40. @ai_providers = AiProvider.active.ordered
  41. end
  42. # PATCH/PUT /admin/ai_agents/:id
  43. def update
  44. @ai_providers = AiProvider.active.ordered
  45. if @ai_agent.update(ai_agent_params)
  46. redirect_to admin_ai_agents_path, notice: 'AI Agent updated successfully.'
  47. else
  48. render :edit, status: :unprocessable_content
  49. end
  50. end
  51. # DELETE /admin/ai_agents/:id
  52. def destroy
  53. @ai_agent.destroy
  54. redirect_to admin_ai_agents_path, notice: 'AI Agent deleted successfully.'
  55. end
  56. # PATCH /admin/ai_agents/:id/toggle
  57. def toggle
  58. @ai_agent.update(active: !@ai_agent.active)
  59. redirect_to admin_ai_agents_path, notice: "AI Agent #{@ai_agent.active? ? 'activated' : 'deactivated'}."
  60. end
  61. # POST /admin/ai_agents/:id/test
  62. def test
  63. user_input = params[:user_input] || "Test input"
  64. context = params[:context] || {}
  65. begin
  66. result = @ai_agent.execute(user_input, context, current_user)
  67. respond_to do |format|
  68. format.json { render json: { success: true, result: result } }
  69. format.html { redirect_to admin_ai_agent_path(@ai_agent), notice: "Test completed successfully." }
  70. end
  71. rescue => e
  72. Rails.logger.error "AI Agent test failed: #{e.message}"
  73. Rails.logger.error e.backtrace.join("\n")
  74. respond_to do |format|
  75. format.json { render json: { success: false, error: e.message }, status: :unprocessable_content }
  76. format.html { redirect_to admin_ai_agent_path(@ai_agent), alert: "Test failed: #{e.message}" }
  77. end
  78. end
  79. end
  80. private
  81. def set_ai_agent
  82. @ai_agent = AiAgent.find(params[:id])
  83. end
  84. def ai_agent_params
  85. params.require(:ai_agent).permit(
  86. :name, :description, :agent_type, :prompt, :content, :guidelines,
  87. :rules, :tasks, :master_prompt, :ai_provider_id, :active, :position
  88. )
  89. end
  90. def calculate_total_usage
  91. # Calculate total usage across all agents from real data
  92. {
  93. total_requests: AiUsage.count,
  94. total_tokens: AiUsage.sum(:tokens_used),
  95. total_cost: AiUsage.sum(:cost),
  96. requests_today: AiUsage.today.count,
  97. requests_this_month: AiUsage.this_month.count,
  98. average_response_time: AiUsage.average(:response_time)&.round(2) || 0
  99. }
  100. end
  101. def calculate_agent_usage(agent)
  102. # Calculate usage statistics for a specific agent from real data
  103. {
  104. total_requests: agent.total_requests,
  105. total_tokens: agent.total_tokens,
  106. total_cost: agent.total_cost,
  107. requests_today: agent.requests_today,
  108. requests_this_month: agent.requests_this_month,
  109. average_response_time: agent.average_response_time,
  110. last_used: agent.last_used,
  111. success_rate: agent.success_rate
  112. }
  113. end
  114. end

app/controllers/admin/ai_demo_controller.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::AiDemoController < Admin::BaseController
  2. def index
  3. # Demo page for AI text generator functionality
  4. # No authentication required for demo purposes
  5. end
  6. end

app/controllers/admin/ai_providers_controller.rb

0.0% lines covered

100.0% branches covered

54 relevant lines. 0 lines covered and 54 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::AiProvidersController < Admin::BaseController
  2. before_action :set_ai_provider, only: [:show, :edit, :update, :destroy, :toggle]
  3. # GET /admin/ai_providers
  4. def index
  5. @ai_providers = AiProvider.ordered.includes(:ai_agents)
  6. end
  7. # GET /admin/ai_providers/:id
  8. def show
  9. end
  10. # GET /admin/ai_providers/new
  11. def new
  12. @ai_provider = AiProvider.new
  13. end
  14. # POST /admin/ai_providers
  15. def create
  16. @ai_provider = AiProvider.new(ai_provider_params)
  17. if @ai_provider.save
  18. redirect_to admin_ai_providers_path, notice: 'AI Provider created successfully.'
  19. else
  20. render :new, status: :unprocessable_entity
  21. end
  22. end
  23. # GET /admin/ai_providers/:id/edit
  24. def edit
  25. end
  26. # PATCH/PUT /admin/ai_providers/:id
  27. def update
  28. update_params = ai_provider_params
  29. # Don't update API key if it's the placeholder
  30. if update_params[:api_key] == "••••••••••••••••"
  31. update_params.delete(:api_key)
  32. end
  33. if @ai_provider.update(update_params)
  34. redirect_to admin_ai_providers_path, notice: 'AI Provider updated successfully.'
  35. else
  36. render :edit, status: :unprocessable_entity
  37. end
  38. end
  39. # DELETE /admin/ai_providers/:id
  40. def destroy
  41. if @ai_provider.ai_agents.any?
  42. redirect_to admin_ai_providers_path, alert: 'Cannot delete provider with active agents. Delete agents first.'
  43. else
  44. @ai_provider.destroy
  45. redirect_to admin_ai_providers_path, notice: 'AI Provider deleted successfully.'
  46. end
  47. end
  48. # PATCH /admin/ai_providers/:id/toggle
  49. def toggle
  50. @ai_provider.update(active: !@ai_provider.active)
  51. redirect_to admin_ai_providers_path, notice: "AI Provider #{@ai_provider.active? ? 'activated' : 'deactivated'}."
  52. end
  53. private
  54. def set_ai_provider
  55. @ai_provider = AiProvider.find(params[:id])
  56. end
  57. def ai_provider_params
  58. params.require(:ai_provider).permit(
  59. :name, :provider_type, :api_key, :api_url, :model_identifier,
  60. :max_tokens, :temperature, :active, :position
  61. )
  62. end
  63. end

app/controllers/admin/analytics_controller.rb

0.0% lines covered

100.0% branches covered

518 relevant lines. 0 lines covered and 518 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::AnalyticsController < Admin::BaseController
  2. def index
  3. @period = params[:period] || 'month'
  4. range = period_range(@period)
  5. # Core metrics
  6. @total_pageviews = Pageview.where(created_at: range).count
  7. @unique_visitors = Pageview.where(created_at: range).distinct.count(:session_id)
  8. @avg_session_duration = calculate_avg_session_duration(range)
  9. @bounce_rate = calculate_bounce_rate(range)
  10. @pages_per_session = calculate_pages_per_session(range)
  11. @current_pageviews = Pageview.where(created_at: 1.hour.ago..Time.current).count
  12. # Country data
  13. @country_data = Pageview.where(created_at: range)
  14. .where.not(country_name: [nil, ''])
  15. .group(:country_name)
  16. .count
  17. .map { |name, count| { name: name, count: count } }
  18. .sort_by { |item| -item[:count] }
  19. .first(10)
  20. # Device data
  21. @device_data = Pageview.where(created_at: range)
  22. .where.not(device: [nil, ''])
  23. .group(:device)
  24. .count
  25. .map { |type, count| { type: type, count: count } }
  26. .sort_by { |item| -item[:count] }
  27. .first(10)
  28. # Top referrers
  29. @top_referrers = Pageview.where(visited_at: range)
  30. .where.not(referrer: [nil, ''])
  31. .group(:referrer)
  32. .count
  33. .map { |referrer, count| { referrer: referrer, count: count } }
  34. .sort_by { |item| -item[:count] }
  35. .first(10)
  36. # Traffic data for charts
  37. @traffic_data = Pageview.where(created_at: range)
  38. .group("DATE(created_at)")
  39. .count
  40. .map { |date, count| { date: date.to_s, count: count } }
  41. # Top pages
  42. @top_pages = Pageview.where(created_at: range)
  43. .where.not(path: [nil, ''])
  44. .group(:path)
  45. .count
  46. .map { |path, count| { path: path, count: count } }
  47. .sort_by { |item| -item[:count] }
  48. .first(10)
  49. # Browser and device stats
  50. @browser_stats = Pageview.where(created_at: range)
  51. .where.not(browser: [nil, ''])
  52. .group(:browser)
  53. .count
  54. @device_stats = Pageview.where(created_at: range)
  55. .where.not(device: [nil, ''])
  56. .group(:device)
  57. .count
  58. @os_stats = Pageview.where(created_at: range)
  59. .where.not(os: [nil, ''])
  60. .group(:os)
  61. .count
  62. # Set audience insights
  63. @audience_insights = AnalyticsService.audience_insights(period: @period)
  64. @operating_systems = @audience_insights[:operating_systems] || []
  65. end
  66. # GET /admin/analytics/realtime
  67. def realtime
  68. # REAL-TIME: Last 10 minutes only
  69. @current_pageviews = Pageview.where(created_at: 10.minutes.ago..Time.current).count
  70. @recent_pageviews = Pageview.where(created_at: 10.minutes.ago..Time.current)
  71. .order(created_at: :desc)
  72. .limit(50)
  73. end
  74. # GET /admin/analytics/insights
  75. def insights
  76. @period = params[:period] || 'month'
  77. insights = AnalyticsService.generate_insights(period: @period)
  78. render json: insights
  79. end
  80. # GET /admin/analytics/posts
  81. def posts
  82. @period = params[:period] || 'month'
  83. range = period_range(@period)
  84. @top_posts = Pageview.consented_only
  85. .non_bot
  86. .where(visited_at: range)
  87. .where.not(post_id: nil)
  88. .group(:post_id)
  89. .order('count_id DESC')
  90. .limit(50)
  91. .count(:id)
  92. .map do |post_id, count|
  93. post = Post.find_by(id: post_id)
  94. {
  95. post: post,
  96. post_id: post_id,
  97. title: post&.title || "Deleted Post ##{post_id}",
  98. views: count,
  99. unique: Pageview.consented_only.non_bot.where(post_id: post_id, visited_at: range, unique_visitor: true).count
  100. }
  101. end
  102. end
  103. # GET /admin/analytics/pages
  104. def pages
  105. @period = params[:period] || 'month'
  106. range = period_range(@period)
  107. @top_pages = Pageview.consented_only
  108. .non_bot
  109. .where(visited_at: range)
  110. .where.not(page_id: nil)
  111. .group(:page_id)
  112. .order('count_id DESC')
  113. .limit(50)
  114. .count(:id)
  115. .map do |page_id, count|
  116. page_obj = Page.find_by(id: page_id)
  117. {
  118. page: page_obj,
  119. views: count,
  120. unique: Pageview.consented_only.non_bot.where(page_id: page_id, visited_at: range, unique_visitor: true).count
  121. }
  122. end
  123. end
  124. # GET /admin/analytics/countries
  125. def countries
  126. @period = params[:period] || 'month'
  127. range = period_range(@period)
  128. @country_stats = Pageview.consented_only
  129. .non_bot
  130. .where(visited_at: range)
  131. .where.not(country_code: nil)
  132. .group(:country_code)
  133. .order('count_id DESC')
  134. .count(:id)
  135. .map { |code, count| { code: code, name: country_name(code), count: count } }
  136. end
  137. # GET /admin/analytics/browsers
  138. def browsers
  139. @period = params[:period] || 'month'
  140. range = period_range(@period)
  141. @browser_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:browser).count
  142. @device_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:device).count
  143. @os_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:os).count
  144. end
  145. # GET /admin/analytics/referrers
  146. def referrers
  147. @period = params[:period] || 'month'
  148. range = period_range(@period)
  149. @referrer_stats = Pageview.consented_only
  150. .non_bot
  151. .where(visited_at: range)
  152. .where.not(referrer: [nil, ''])
  153. .group(:referrer)
  154. .order('count_id DESC')
  155. .limit(50)
  156. .count(:id)
  157. end
  158. # GET /admin/analytics/export
  159. def export
  160. @period = params[:period] || 'month'
  161. range = period_range(@period)
  162. pageviews = Pageview.consented_only
  163. .where(visited_at: range)
  164. .order(visited_at: :desc)
  165. csv_data = generate_csv(pageviews)
  166. send_data csv_data,
  167. filename: "analytics-#{@period}-#{Date.today}.csv",
  168. type: 'text/csv',
  169. disposition: 'attachment'
  170. end
  171. # POST /admin/analytics/purge
  172. def purge
  173. days = params[:days]&.to_i || 90
  174. case params[:purge_type]
  175. when 'anonymize'
  176. Pageview.anonymize_old_data(days)
  177. message = "Data older than #{days} days has been anonymized."
  178. when 'delete_non_consented'
  179. count = Pageview.purge_non_consented(days)
  180. message = "Deleted #{count} non-consented pageviews older than #{days} days."
  181. when 'delete_all'
  182. count = Pageview.where('created_at < ?', days.days.ago).delete_all
  183. message = "Deleted #{count} pageviews older than #{days} days."
  184. else
  185. message = "Invalid purge type."
  186. end
  187. redirect_to admin_analytics_path, notice: message
  188. end
  189. # POST /analytics/events (for custom event tracking)
  190. def track_event
  191. return head :unauthorized unless request.xhr? || request.content_type&.include?('application/json')
  192. begin
  193. event_data = JSON.parse(request.body.read)
  194. # Create analytics event
  195. event = AnalyticsEvent.create!(
  196. event_name: event_data['event_name'],
  197. properties: event_data['properties'] || {},
  198. session_id: event_data['properties']&.dig('session_id') || generate_session_id,
  199. user_id: current_user&.id,
  200. path: event_data['properties']&.dig('path') || request.path,
  201. tenant: ActsAsTenant.current_tenant
  202. )
  203. render json: { success: true, event_id: event.id }
  204. rescue => e
  205. Rails.logger.error "Failed to track custom event: #{e.message}"
  206. render json: { success: false, error: e.message }, status: :unprocessable_entity
  207. end
  208. end
  209. private
  210. def period_range(period)
  211. case period.to_sym
  212. when :today
  213. Time.current.beginning_of_day..Time.current.end_of_day
  214. when :week
  215. 1.week.ago..Time.current
  216. when :month
  217. 1.month.ago..Time.current
  218. when :year
  219. 1.year.ago..Time.current
  220. else
  221. 1.month.ago..Time.current
  222. end
  223. end
  224. def calculate_consent_rate
  225. total = Pageview.count
  226. return 0 if total.zero?
  227. consented = Pageview.consented_only.count
  228. ((consented.to_f / total) * 100).round(1)
  229. end
  230. def country_name(code)
  231. # Simple country code to name mapping
  232. countries = {
  233. 'US' => 'United States',
  234. 'GB' => 'United Kingdom',
  235. 'CA' => 'Canada',
  236. 'DE' => 'Germany',
  237. 'FR' => 'France',
  238. 'ES' => 'Spain',
  239. 'IT' => 'Italy',
  240. 'BR' => 'Brazil',
  241. 'JP' => 'Japan',
  242. 'CN' => 'China',
  243. 'IN' => 'India',
  244. 'AU' => 'Australia',
  245. 'MX' => 'Mexico',
  246. 'NL' => 'Netherlands'
  247. }
  248. countries[code] || code
  249. end
  250. def pageview_json(pageview)
  251. {
  252. id: pageview.id,
  253. path: pageview.path,
  254. title: pageview.title,
  255. browser: pageview.browser,
  256. device: pageview.device,
  257. country: pageview.country_code,
  258. visited_at: pageview.visited_at.strftime('%Y-%m-%d %H:%M:%S')
  259. }
  260. end
  261. def generate_csv(pageviews)
  262. require 'csv'
  263. CSV.generate(headers: true) do |csv|
  264. csv << ['Date', 'Time', 'Path', 'Title', 'Referrer', 'Country', 'Browser', 'Device', 'OS', 'Duration']
  265. pageviews.each do |pv|
  266. csv << [
  267. pv.visited_at.strftime('%Y-%m-%d'),
  268. pv.visited_at.strftime('%H:%M:%S'),
  269. pv.path,
  270. pv.title,
  271. pv.referrer,
  272. pv.country_code,
  273. pv.browser,
  274. pv.device,
  275. pv.os,
  276. pv.duration
  277. ]
  278. end
  279. end
  280. end
  281. def calculate_engagement_levels(range)
  282. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  283. high_engagement = pageviews.where('time_on_page > ? AND scroll_depth > ?', 60, 75).count
  284. medium_engagement = pageviews.where('time_on_page BETWEEN ? AND ? AND scroll_depth BETWEEN ? AND ?', 30, 60, 25, 75).count
  285. low_engagement = pageviews.where('time_on_page < ? OR scroll_depth < ?', 30, 25).count
  286. [
  287. { level: 'high', count: high_engagement },
  288. { level: 'medium', count: medium_engagement },
  289. { level: 'low', count: low_engagement }
  290. ]
  291. end
  292. def calculate_device_breakdown(range)
  293. Pageview.consented_only
  294. .non_bot
  295. .where(visited_at: range)
  296. .where.not(device: nil)
  297. .group(:device)
  298. .count(:id)
  299. .sort_by { |_, count| -count }
  300. .first(10)
  301. .map { |device, count| { device: device, count: count } }
  302. end
  303. def calculate_country_breakdown(range)
  304. Pageview.consented_only
  305. .non_bot
  306. .where(visited_at: range)
  307. .where.not(country_code: nil)
  308. .group(:country_code)
  309. .count(:id)
  310. .sort_by { |_, count| -count }
  311. .first(10)
  312. .map { |country, count| { country: country, count: count } }
  313. end
  314. def calculate_performance_metrics(range)
  315. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  316. {
  317. page_load_time: 85, # This would come from performance monitoring
  318. time_to_interactive: 78,
  319. first_contentful_paint: 92,
  320. largest_contentful_paint: 88
  321. }
  322. end
  323. def calculate_conversion_funnel(range)
  324. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  325. visitors = pageviews.distinct.count(:session_id)
  326. page_views = pageviews.count
  327. engaged_users = pageviews.where('time_on_page > ?', 30).distinct.count(:session_id)
  328. readers = pageviews.where(is_reader: true).distinct.count(:session_id)
  329. conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').distinct.count(:session_id)
  330. [
  331. { stage: 'Visitors', count: visitors },
  332. { stage: 'Page Views', count: page_views },
  333. { stage: 'Engaged Users', count: engaged_users },
  334. { stage: 'Readers', count: readers },
  335. { stage: 'Conversions', count: conversions }
  336. ]
  337. end
  338. # Advanced GA4/Matomo-level analytics methods
  339. def calculate_traffic_sources(range)
  340. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  341. sources = pageviews.where.not(referrer: [nil, '']).group(:referrer).count(:id)
  342. direct = pageviews.where(referrer: [nil, '']).count
  343. {
  344. organic: sources.select { |k, _| k.include?('google') || k.include?('bing') }.sum { |_, v| v },
  345. social: sources.select { |k, _| k.include?('facebook') || k.include?('twitter') || k.include?('linkedin') }.sum { |_, v| v },
  346. direct: direct,
  347. referral: sources.reject { |k, _| k.include?('google') || k.include?('bing') || k.include?('facebook') || k.include?('twitter') || k.include?('linkedin') }.sum { |_, v| v }
  348. }
  349. end
  350. def calculate_user_flow(range)
  351. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  352. # Calculate user flow through the site
  353. entry_pages = pageviews.group(:session_id).minimum(:visited_at)
  354. exit_pages = pageviews.group(:session_id).maximum(:visited_at)
  355. {
  356. entry_pages: entry_pages.values.group_by { |pv| Pageview.find(pv.id).path }.transform_values(&:count),
  357. exit_pages: exit_pages.values.group_by { |pv| Pageview.find(pv.id).path }.transform_values(&:count),
  358. avg_pages_per_session: pageviews.group(:session_id).count.values.mean || 0
  359. }
  360. end
  361. def calculate_cohort_analysis(range)
  362. # Simple cohort analysis by month
  363. cohorts = {}
  364. (0..12).each do |i|
  365. month_start = i.months.ago.beginning_of_month
  366. month_end = month_start.end_of_month
  367. cohort_users = Pageview.where(visited_at: month_start..month_end).distinct.pluck(:session_id)
  368. cohorts[month_start.strftime('%Y-%m')] = {
  369. users: cohort_users.count,
  370. retention: calculate_cohort_retention(cohort_users, month_start)
  371. }
  372. end
  373. cohorts
  374. end
  375. def calculate_attribution_data(range)
  376. # Multi-touch attribution analysis
  377. sessions = Pageview.where(visited_at: range).distinct.pluck(:session_id)
  378. {
  379. first_touch: calculate_first_touch_attribution(sessions),
  380. last_touch: calculate_last_touch_attribution(sessions),
  381. linear: calculate_linear_attribution(sessions)
  382. }
  383. end
  384. def calculate_bounce_rate(range)
  385. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  386. total_sessions = pageviews.distinct.count(:session_id)
  387. return 0 if total_sessions.zero?
  388. single_page_sessions_count = pageviews.group(:session_id).having('COUNT(*) = 1').count.size
  389. (single_page_sessions_count.to_f / total_sessions * 100).round(2)
  390. end
  391. def calculate_avg_session_duration(range)
  392. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  393. sessions = pageviews.group(:session_id).sum(:time_on_page)
  394. return 0 if sessions.empty?
  395. (sessions.values.sum / sessions.count).round(2)
  396. end
  397. def calculate_pages_per_session(range)
  398. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  399. pages_per_session = pageviews.group(:session_id).count
  400. return 0 if pages_per_session.empty?
  401. (pages_per_session.values.sum / pages_per_session.count.to_f).round(2)
  402. end
  403. def calculate_conversion_rate(range)
  404. total_sessions = Pageview.consented_only.non_bot.where(visited_at: range).distinct.count(:session_id)
  405. conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').distinct.count(:session_id)
  406. return 0 if total_sessions.zero?
  407. (conversions.to_f / total_sessions * 100).round(2)
  408. end
  409. def get_top_posts(range)
  410. Pageview.consented_only
  411. .non_bot
  412. .where(visited_at: range)
  413. .where.not(post_id: nil)
  414. .group(:post_id)
  415. .count(:id)
  416. .sort_by { |_, count| -count }
  417. .first(10)
  418. .map do |post_id, count|
  419. post = Post.find_by(id: post_id)
  420. {
  421. post: post,
  422. post_id: post_id,
  423. title: post&.title || "Deleted Post ##{post_id}",
  424. views: count,
  425. unique_readers: Pageview.consented_only.non_bot.where(post_id: post_id, visited_at: range, is_reader: true).count
  426. }
  427. end
  428. end
  429. def get_top_pages(range)
  430. Pageview.consented_only
  431. .non_bot
  432. .where(visited_at: range)
  433. .where.not(page_id: nil)
  434. .group(:page_id)
  435. .count(:id)
  436. .sort_by { |_, count| -count }
  437. .first(10)
  438. .map do |page_id, count|
  439. page = Page.find_by(id: page_id)
  440. {
  441. page: page,
  442. page_id: page_id,
  443. title: page&.title || "Deleted Page ##{page_id}",
  444. views: count,
  445. unique_readers: Pageview.consented_only.non_bot.where(page_id: page_id, visited_at: range, is_reader: true).count
  446. }
  447. end
  448. end
  449. def calculate_content_engagement(range)
  450. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  451. {
  452. avg_reading_time: pageviews.average(:reading_time)&.round(2) || 0,
  453. avg_scroll_depth: pageviews.average(:scroll_depth)&.round(2) || 0,
  454. avg_completion_rate: pageviews.average(:completion_rate)&.round(2) || 0,
  455. readers_count: pageviews.where(is_reader: true).count,
  456. high_engagement_readers: pageviews.where(is_reader: true, engagement_score: 80..100).count
  457. }
  458. end
  459. def calculate_geographic_insights(range)
  460. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  461. {
  462. top_countries: pageviews.where.not(country_code: nil).group(:country_code).count.sort_by { |_, v| -v }.first(10),
  463. top_cities: pageviews.where.not(city: nil).group(:city).count.sort_by { |_, v| -v }.first(10),
  464. top_regions: pageviews.where.not(region: nil).group(:region).count.sort_by { |_, v| -v }.first(10)
  465. }
  466. end
  467. def calculate_technology_insights(range)
  468. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  469. {
  470. browsers: pageviews.where.not(browser: nil).group(:browser).count.sort_by { |_, v| -v },
  471. devices: pageviews.where.not(device: nil).group(:device).count.sort_by { |_, v| -v },
  472. operating_systems: pageviews.where.not(os: nil).group(:os).count.sort_by { |_, v| -v }
  473. }
  474. end
  475. def calculate_time_insights(range)
  476. pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
  477. {
  478. hourly_distribution: pageviews.group_by_hour(:visited_at).count,
  479. daily_distribution: pageviews.group_by_day(:visited_at).count,
  480. weekly_distribution: pageviews.group_by_day_of_week(:visited_at).count,
  481. monthly_distribution: pageviews.group_by_month(:visited_at).count
  482. }
  483. end
  484. def calculate_cohort_retention(cohort_users, cohort_month)
  485. # Calculate how many users from this cohort returned in subsequent months
  486. returning_users = 0
  487. (1..12).each do |i|
  488. next_month = cohort_month + i.months
  489. if next_month <= Time.current
  490. next_month_users = Pageview.where(visited_at: next_month.beginning_of_month..next_month.end_of_month)
  491. .where(session_id: cohort_users)
  492. .distinct
  493. .pluck(:session_id)
  494. returning_users += next_month_users.count
  495. end
  496. end
  497. cohort_users.empty? ? 0 : (returning_users.to_f / cohort_users.count * 100).round(2)
  498. end
  499. def calculate_first_touch_attribution(sessions)
  500. # Calculate first-touch attribution
  501. first_touches = {}
  502. sessions.each do |session_id|
  503. first_pageview = Pageview.where(session_id: session_id).order(:visited_at).first
  504. next unless first_pageview&.referrer.present?
  505. source = categorize_traffic_source(first_pageview.referrer)
  506. first_touches[source] = (first_touches[source] || 0) + 1
  507. end
  508. first_touches
  509. end
  510. def calculate_last_touch_attribution(sessions)
  511. # Calculate last-touch attribution
  512. last_touches = {}
  513. sessions.each do |session_id|
  514. last_pageview = Pageview.where(session_id: session_id).order(:visited_at).last
  515. next unless last_pageview&.referrer.present?
  516. source = categorize_traffic_source(last_pageview.referrer)
  517. last_touches[source] = (last_touches[source] || 0) + 1
  518. end
  519. last_touches
  520. end
  521. def calculate_linear_attribution(sessions)
  522. # Calculate linear attribution (equal weight to all touchpoints)
  523. linear_attribution = {}
  524. sessions.each do |session_id|
  525. pageviews = Pageview.where(session_id: session_id).where.not(referrer: [nil, ''])
  526. next if pageviews.empty?
  527. weight = 1.0 / pageviews.count
  528. pageviews.each do |pv|
  529. source = categorize_traffic_source(pv.referrer)
  530. linear_attribution[source] = (linear_attribution[source] || 0) + weight
  531. end
  532. end
  533. linear_attribution
  534. end
  535. def categorize_traffic_source(referrer)
  536. return 'Direct' if referrer.blank?
  537. if referrer.include?('google') || referrer.include?('bing') || referrer.include?('yahoo')
  538. 'Organic Search'
  539. elsif referrer.include?('facebook') || referrer.include?('twitter') || referrer.include?('linkedin') || referrer.include?('instagram')
  540. 'Social Media'
  541. elsif referrer.include?('mail') || referrer.include?('email')
  542. 'Email'
  543. else
  544. 'Referral'
  545. end
  546. end
  547. end

app/controllers/admin/api_docs_controller.rb

0.0% lines covered

100.0% branches covered

447 relevant lines. 0 lines covered and 447 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. require 'redcarpet'
  2. class Admin::ApiDocsController < Admin::BaseController
  3. def index
  4. # Main API documentation landing page
  5. end
  6. def rest
  7. # REST API documentation
  8. load_rest_endpoints
  9. end
  10. def graphql
  11. # GraphQL playground (moved from /graphiql)
  12. if Rails.env.production?
  13. redirect_to admin_api_docs_path, alert: "GraphQL playground is only available in development mode."
  14. else
  15. render layout: false
  16. end
  17. end
  18. def graphql_schema
  19. # GraphQL schema documentation
  20. @schema = RailspressSchema
  21. @types = @schema.types.values.reject { |type| type.graphql_name.start_with?('__') }
  22. @queries = @schema.query.fields.values
  23. @mutations = @schema.mutation&.fields&.values || []
  24. end
  25. def themes
  26. # Theme development documentation
  27. @theme_docs = load_theme_docs
  28. @theme_docs_content = load_html_documentation('theme-development.html')
  29. end
  30. def plugins
  31. # Plugin development documentation
  32. @plugin_docs = load_plugin_docs
  33. @plugin_docs_content = load_html_documentation('plugin-development.html')
  34. end
  35. private
  36. def load_rest_endpoints
  37. @endpoints = {
  38. authentication: {
  39. title: "Authentication",
  40. description: "Endpoints for user authentication and token management",
  41. endpoints: [
  42. {
  43. method: "POST",
  44. path: "/api/v1/auth/login",
  45. description: "Login and receive an API token",
  46. params: {
  47. email: "string (required)",
  48. password: "string (required)"
  49. },
  50. response: {
  51. token: "string",
  52. user: "object"
  53. }
  54. },
  55. {
  56. method: "POST",
  57. path: "/api/v1/auth/register",
  58. description: "Register a new user account",
  59. params: {
  60. email: "string (required)",
  61. password: "string (required)",
  62. password_confirmation: "string (required)",
  63. name: "string (optional)"
  64. },
  65. response: {
  66. token: "string",
  67. user: "object"
  68. }
  69. },
  70. {
  71. method: "POST",
  72. path: "/api/v1/auth/validate",
  73. description: "Validate an API token",
  74. headers: {
  75. Authorization: "Bearer YOUR_TOKEN"
  76. },
  77. response: {
  78. valid: "boolean",
  79. user: "object"
  80. }
  81. }
  82. ]
  83. },
  84. posts: {
  85. title: "Posts",
  86. description: "Create, read, update, and delete blog posts",
  87. endpoints: [
  88. {
  89. method: "GET",
  90. path: "/api/v1/posts",
  91. description: "List all posts with pagination",
  92. params: {
  93. page: "integer (default: 1)",
  94. per_page: "integer (default: 20)",
  95. status: "string (published, draft, private_post)",
  96. search: "string (search query)"
  97. },
  98. response: {
  99. posts: "array",
  100. meta: {
  101. total: "integer",
  102. page: "integer",
  103. per_page: "integer",
  104. total_pages: "integer"
  105. }
  106. }
  107. },
  108. {
  109. method: "GET",
  110. path: "/api/v1/posts/:id",
  111. description: "Get a specific post by ID",
  112. response: {
  113. id: "integer",
  114. title: "string",
  115. content: "string",
  116. excerpt: "string",
  117. slug: "string",
  118. status: "string",
  119. published_at: "datetime",
  120. categories: "array",
  121. tags: "array",
  122. user: "object"
  123. }
  124. },
  125. {
  126. method: "POST",
  127. path: "/api/v1/posts",
  128. description: "Create a new post",
  129. requires_auth: true,
  130. params: {
  131. title: "string (required)",
  132. content: "string (required)",
  133. excerpt: "string (optional)",
  134. slug: "string (optional)",
  135. status: "string (default: draft)",
  136. category_ids: "array of integers",
  137. tag_ids: "array of integers"
  138. },
  139. response: {
  140. post: "object",
  141. message: "string"
  142. }
  143. },
  144. {
  145. method: "PUT",
  146. path: "/api/v1/posts/:id",
  147. description: "Update an existing post",
  148. requires_auth: true,
  149. params: {
  150. title: "string",
  151. content: "string",
  152. excerpt: "string",
  153. status: "string",
  154. category_ids: "array of integers",
  155. tag_ids: "array of integers"
  156. }
  157. },
  158. {
  159. method: "DELETE",
  160. path: "/api/v1/posts/:id",
  161. description: "Delete a post",
  162. requires_auth: true,
  163. response: {
  164. message: "string"
  165. }
  166. }
  167. ]
  168. },
  169. pages: {
  170. title: "Pages",
  171. description: "Manage static pages",
  172. endpoints: [
  173. {
  174. method: "GET",
  175. path: "/api/v1/pages",
  176. description: "List all pages"
  177. },
  178. {
  179. method: "GET",
  180. path: "/api/v1/pages/:id",
  181. description: "Get a specific page"
  182. },
  183. {
  184. method: "POST",
  185. path: "/api/v1/pages",
  186. description: "Create a new page",
  187. requires_auth: true
  188. },
  189. {
  190. method: "PUT",
  191. path: "/api/v1/pages/:id",
  192. description: "Update a page",
  193. requires_auth: true
  194. },
  195. {
  196. method: "DELETE",
  197. path: "/api/v1/pages/:id",
  198. description: "Delete a page",
  199. requires_auth: true
  200. }
  201. ]
  202. },
  203. taxonomies: {
  204. title: "Taxonomies & Terms",
  205. description: "Manage taxonomies (categories, tags, custom) and their terms",
  206. endpoints: [
  207. {
  208. method: "GET",
  209. path: "/api/v1/taxonomies",
  210. description: "List all taxonomies",
  211. response: {
  212. taxonomies: "array of taxonomy objects"
  213. }
  214. },
  215. {
  216. method: "GET",
  217. path: "/api/v1/taxonomies/:id",
  218. description: "Get a specific taxonomy with its terms"
  219. },
  220. {
  221. method: "GET",
  222. path: "/api/v1/taxonomies/:id/terms",
  223. description: "Get all terms for a taxonomy"
  224. },
  225. {
  226. method: "GET",
  227. path: "/api/v1/terms",
  228. description: "List all terms across taxonomies"
  229. },
  230. {
  231. method: "GET",
  232. path: "/api/v1/terms/:id",
  233. description: "Get a specific term"
  234. }
  235. ]
  236. },
  237. comments: {
  238. title: "Comments",
  239. description: "Manage post and page comments",
  240. endpoints: [
  241. {
  242. method: "GET",
  243. path: "/api/v1/posts/:post_id/comments",
  244. description: "Get comments for a specific post"
  245. },
  246. {
  247. method: "POST",
  248. path: "/api/v1/posts/:post_id/comments",
  249. description: "Create a new comment on a post",
  250. params: {
  251. content: "string (required)",
  252. author_name: "string (required)",
  253. author_email: "string (required)",
  254. author_url: "string (optional)",
  255. parent_id: "integer (optional, for replies)"
  256. }
  257. },
  258. {
  259. method: "PATCH",
  260. path: "/api/v1/comments/:id/approve",
  261. description: "Approve a comment",
  262. requires_auth: true
  263. },
  264. {
  265. method: "PATCH",
  266. path: "/api/v1/comments/:id/spam",
  267. description: "Mark a comment as spam",
  268. requires_auth: true
  269. }
  270. ]
  271. },
  272. media: {
  273. title: "Media",
  274. description: "Upload and manage media files",
  275. endpoints: [
  276. {
  277. method: "GET",
  278. path: "/api/v1/media",
  279. description: "List all media files"
  280. },
  281. {
  282. method: "POST",
  283. path: "/api/v1/media",
  284. description: "Upload a new media file",
  285. requires_auth: true,
  286. params: {
  287. file: "multipart/form-data (required)"
  288. }
  289. },
  290. {
  291. method: "DELETE",
  292. path: "/api/v1/media/:id",
  293. description: "Delete a media file",
  294. requires_auth: true
  295. }
  296. ]
  297. },
  298. users: {
  299. title: "Users",
  300. description: "User management endpoints",
  301. endpoints: [
  302. {
  303. method: "GET",
  304. path: "/api/v1/users",
  305. description: "List all users",
  306. requires_auth: true
  307. },
  308. {
  309. method: "GET",
  310. path: "/api/v1/users/me",
  311. description: "Get current authenticated user",
  312. requires_auth: true
  313. },
  314. {
  315. method: "PATCH",
  316. path: "/api/v1/users/update_profile",
  317. description: "Update current user profile",
  318. requires_auth: true
  319. }
  320. ]
  321. },
  322. ai_agents: {
  323. title: "AI Agents",
  324. description: "Execute AI agents for content generation and analysis",
  325. endpoints: [
  326. {
  327. method: "GET",
  328. path: "/api/v1/ai_agents",
  329. description: "List all available AI agents"
  330. },
  331. {
  332. method: "POST",
  333. path: "/api/v1/ai_agents/execute/:agent_type",
  334. description: "Execute an AI agent by type",
  335. requires_auth: true,
  336. params: {
  337. agent_type: "string (content_summarizer, post_writer, comments_analyzer, seo_analyzer)",
  338. input: "string (required - the content to process)"
  339. },
  340. response: {
  341. result: "string (AI-generated content)",
  342. agent: "object",
  343. success: "boolean"
  344. }
  345. }
  346. ]
  347. },
  348. settings: {
  349. title: "Settings",
  350. description: "Site settings and configuration",
  351. endpoints: [
  352. {
  353. method: "GET",
  354. path: "/api/v1/settings",
  355. description: "List all settings",
  356. requires_auth: true
  357. },
  358. {
  359. method: "GET",
  360. path: "/api/v1/settings/get/:key",
  361. description: "Get a specific setting value"
  362. }
  363. ]
  364. },
  365. system: {
  366. title: "System",
  367. description: "System information and statistics",
  368. endpoints: [
  369. {
  370. method: "GET",
  371. path: "/api/v1/system/info",
  372. description: "Get system information"
  373. },
  374. {
  375. method: "GET",
  376. path: "/api/v1/system/stats",
  377. description: "Get site statistics",
  378. response: {
  379. posts_count: "integer",
  380. pages_count: "integer",
  381. users_count: "integer",
  382. comments_count: "integer"
  383. }
  384. }
  385. ]
  386. }
  387. }
  388. end
  389. def load_plugin_docs
  390. docs_path = Rails.root.join('docs', 'plugins')
  391. docs = []
  392. if Dir.exist?(docs_path)
  393. Dir.glob(File.join(docs_path, '*.md')).each do |file|
  394. filename = File.basename(file, '.md')
  395. content = File.read(file)
  396. # Extract title from markdown (first # heading)
  397. title = content.match(/^#\s+(.+)$/m)&.[](1) || filename.titleize
  398. docs << {
  399. title: title,
  400. filename: filename,
  401. path: file,
  402. content: content,
  403. url: "/docs/plugins/#{filename}.md"
  404. }
  405. end
  406. end
  407. # Add core plugin documentation files
  408. [
  409. { title: "Plugin Quick Start", path: Rails.root.join('docs', 'PLUGIN_QUICK_START.md') },
  410. { title: "Plugin MVC Architecture", path: Rails.root.join('docs', 'PLUGIN_MVC_ARCHITECTURE.md') },
  411. { title: "Plugin Developer Guide", path: Rails.root.join('docs', 'PLUGIN_DEVELOPER_GUIDE.md') }
  412. ].each do |doc|
  413. if File.exist?(doc[:path])
  414. content = File.read(doc[:path])
  415. docs.unshift({
  416. title: doc[:title],
  417. filename: File.basename(doc[:path], '.md'),
  418. path: doc[:path],
  419. content: content,
  420. url: "/docs/#{File.basename(doc[:path])}"
  421. })
  422. end
  423. end
  424. docs.sort_by { |d| d[:title] }
  425. end
  426. def load_theme_docs
  427. docs_path = Rails.root.join('docs', 'themes')
  428. docs = []
  429. if Dir.exist?(docs_path)
  430. Dir.glob(File.join(docs_path, '*.md')).each do |file|
  431. filename = File.basename(file, '.md')
  432. content = File.read(file)
  433. # Extract title from markdown
  434. title = content.match(/^#\s+(.+)$/m)&.[](1) || filename.titleize
  435. docs << {
  436. title: title,
  437. filename: filename,
  438. path: file,
  439. content: content,
  440. url: "/docs/themes/#{filename}.md"
  441. }
  442. end
  443. end
  444. docs.sort_by { |d| d[:title] }
  445. end
  446. def load_html_documentation(filename)
  447. docs_path = Rails.root.join('docs', filename)
  448. return nil unless File.exist?(docs_path)
  449. begin
  450. File.read(docs_path)
  451. rescue => e
  452. Rails.logger.error "Failed to load HTML documentation: #{e.message}"
  453. nil
  454. end
  455. end
  456. end

app/controllers/admin/base_controller.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::BaseController < ApplicationController
  2. before_action :authenticate_user!
  3. before_action :ensure_admin_access
  4. after_action :clear_flash_messages
  5. layout 'admin'
  6. private
  7. def ensure_admin_access
  8. unless current_user&.author? || current_user&.editor? || current_user&.administrator?
  9. redirect_to root_path, alert: 'You do not have permission to access the admin area.'
  10. end
  11. end
  12. def ensure_editor_access
  13. unless current_user&.editor? || current_user&.administrator?
  14. redirect_to admin_root_path, alert: 'You do not have permission to perform this action.'
  15. end
  16. end
  17. def ensure_admin
  18. unless current_user&.administrator?
  19. redirect_to admin_root_path, alert: 'Only administrators can perform this action.'
  20. end
  21. end
  22. def clear_flash_messages
  23. # Clear flash messages after they've been displayed
  24. # This prevents them from persisting across page loads
  25. flash.clear
  26. end
  27. end

app/controllers/admin/builder_controller.rb

0.0% lines covered

100.0% branches covered

1076 relevant lines. 0 lines covered and 1076 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::BuilderController < Admin::BaseController
  2. before_action :set_current_theme, only: [:index, :show, :create_version, :save_draft, :publish, :rollback, :preview, :sections, :update_section, :reorder_sections, :remove_section, :add_section, :add_block, :remove_block, :update_block, :update_theme_settings]
  3. before_action :set_builder_theme, only: [:show, :save_draft, :publish, :rollback, :preview, :sections, :update_section, :reorder_sections, :remove_section, :add_section, :add_block, :remove_block, :update_block, :update_theme_settings]
  4. before_action :ensure_editor_access, except: [:preview, :save_draft]
  5. skip_before_action :verify_authenticity_token, only: [:preview]
  6. # GET /admin/builder
  7. def index
  8. @current_theme_name = @current_theme&.name&.underscore || 'default'
  9. @builder_theme = BuilderTheme.draft_for_theme(@current_theme_name) ||
  10. BuilderTheme.current_for_theme(@current_theme_name)
  11. if @builder_theme
  12. redirect_to admin_builder_path(@builder_theme)
  13. else
  14. # Create initial version
  15. @builder_theme = BuilderTheme.create_version(@current_theme_name, current_user)
  16. redirect_to admin_builder_path(@builder_theme)
  17. end
  18. end
  19. # GET /admin/builder/:id
  20. def show
  21. @current_theme_name = @current_theme&.name&.underscore || 'default'
  22. @versions = BuilderTheme.for_theme(@current_theme_name).latest.limit(10)
  23. @snapshots = BuilderThemeSnapshot.for_theme(@current_theme_name).latest.limit(10)
  24. # Get available templates
  25. @available_templates = get_available_templates
  26. # Load current template (default to index)
  27. @current_template_name = params[:template] || 'index'
  28. # Get template data from ThemePreview (new system)
  29. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, @current_template_name)
  30. # Clean up any duplicate sections first
  31. theme_preview.cleanup_duplicates!
  32. @current_page_sections = {}
  33. @section_order = []
  34. # Build sections hash from ThemePreviewSection records
  35. Rails.logger.info "=== SHOW ACTION DEBUG ==="
  36. Rails.logger.info "ThemePreview sections count: #{theme_preview.ordered_sections.count}"
  37. # DEDUPLICATE SECTIONS: Group by section_id and take the latest one
  38. sections_by_id = theme_preview.ordered_sections.group_by(&:section_id)
  39. Rails.logger.info "Sections grouped by ID: #{sections_by_id.keys}"
  40. sections_by_id.each do |section_id, sections|
  41. # Take the latest section if there are duplicates
  42. section = sections.max_by(&:updated_at)
  43. Rails.logger.info "Using section #{section_id} with position #{section.position} (from #{sections.count} duplicates)"
  44. @current_page_sections[section_id] = {
  45. 'type' => section.section_type,
  46. 'settings' => section.settings
  47. }
  48. @section_order << section_id
  49. end
  50. Rails.logger.info "Final section_order: #{@section_order}"
  51. Rails.logger.info "Final current_page_sections keys: #{@current_page_sections.keys}"
  52. Rails.logger.info "Section order has duplicates: #{@section_order.length != @section_order.uniq.length}"
  53. Rails.logger.info "Section order unique count: #{@section_order.uniq.length}"
  54. # FORCE DEDUPLICATION: Always deduplicate section order
  55. Rails.logger.warn "FORCE DEDUPLICATION: Original order #{@section_order.length}, unique order #{@section_order.uniq.length}"
  56. @section_order = @section_order.uniq
  57. Rails.logger.info "FINAL CLEAN section_order: #{@section_order}"
  58. @theme_schema = load_theme_schema
  59. render layout: 'builder'
  60. end
  61. # POST /admin/builder/:id/create_version
  62. def create_version
  63. parent_version = @builder_theme
  64. label = params[:label] || "Version #{Time.current.strftime('%Y%m%d_%H%M%S')}"
  65. new_version = BuilderTheme.create_version(
  66. @current_theme_name,
  67. current_user,
  68. parent_version,
  69. label
  70. )
  71. # Copy all files from parent version
  72. parent_version.builder_theme_files.each do |file|
  73. new_version.builder_theme_files.create!(
  74. path: file.path,
  75. content: file.content,
  76. checksum: file.checksum,
  77. file_size: file.file_size,
  78. tenant: new_version.tenant
  79. )
  80. end
  81. respond_to do |format|
  82. format.json { render json: { success: true, version_id: new_version.id, redirect_url: admin_builder_path(new_version) } }
  83. format.html { redirect_to admin_builder_path(new_version), notice: 'New version created successfully!' }
  84. end
  85. end
  86. # PATCH /admin/builder/:id/autosave
  87. def autosave
  88. sections_data = JSON.parse(params[:sections_data] || params.dig(:builder, :sections_data) || '{}')
  89. settings_data = JSON.parse(params[:settings_data] || params.dig(:builder, :settings_data) || '{}')
  90. template = params[:template] || params.dig(:builder, :template) || 'index'
  91. begin
  92. Rails.logger.info "Autosave triggered for template: #{template}"
  93. # Get or create theme preview for this template
  94. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  95. # Update sections in ThemePreview (only if sections_data is provided)
  96. if sections_data.present?
  97. # Get existing sections for efficient updates
  98. existing_sections = theme_preview.theme_preview_sections.index_by(&:section_id)
  99. processed_section_ids = []
  100. # Create or update sections from the data
  101. sections_data.each_with_index do |(section_id, section_config), index|
  102. processed_section_ids << section_id
  103. if existing_sections[section_id]
  104. # Update existing section
  105. existing_sections[section_id].update!(
  106. section_type: section_config['type'] || section_id,
  107. settings: section_config['settings'] || {},
  108. position: index
  109. )
  110. else
  111. # Create new section
  112. theme_preview.theme_preview_sections.create!(
  113. section_id: section_id,
  114. section_type: section_config['type'] || section_id,
  115. settings: section_config['settings'] || {},
  116. position: index
  117. )
  118. end
  119. end
  120. # Remove sections that are no longer in the data
  121. sections_to_remove = existing_sections.keys - processed_section_ids
  122. sections_to_remove.each do |section_id|
  123. existing_sections[section_id]&.destroy!
  124. end
  125. end
  126. # Update theme settings in ThemePreviewFile (only if settings_data is provided)
  127. if settings_data.present?
  128. ThemePreviewFile.update_template_content(
  129. @builder_theme,
  130. 'settings_data',
  131. settings_data
  132. )
  133. end
  134. # Update individual files in ThemePreviewFile if provided
  135. if params[:files].present?
  136. params[:files].each do |file_path, content|
  137. preview_file = @builder_theme.theme_preview_files.find_or_create_by(
  138. file_path: file_path,
  139. file_type: 'custom'
  140. ) do |file|
  141. file.tenant = @builder_theme.tenant
  142. end
  143. preview_file.update!(content: content)
  144. end
  145. end
  146. respond_to do |format|
  147. format.json { render json: { success: true, message: 'Autosaved successfully!' } }
  148. end
  149. rescue => e
  150. Rails.logger.error "Autosave failed: #{e.message}"
  151. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  152. Rails.logger.error "Params: #{params.inspect}"
  153. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  154. end
  155. end
  156. # PATCH /admin/builder/:id/save_draft
  157. def save_draft
  158. Rails.logger.info "=== SAVE DRAFT CALLED ==="
  159. Rails.logger.info "Params: #{params.inspect}"
  160. Rails.logger.info "@builder_theme: #{@builder_theme.inspect}"
  161. # Check if @builder_theme is nil
  162. if @builder_theme.nil?
  163. Rails.logger.error "ERROR: @builder_theme is nil!"
  164. render json: { success: false, errors: ['Builder theme not found'] }, status: :not_found
  165. return
  166. end
  167. sections_data = JSON.parse(params[:sections_data] || params.dig(:builder, :sections_data) || '{}')
  168. settings_data = JSON.parse(params[:settings_data] || params.dig(:builder, :settings_data) || '{}')
  169. template = params[:template] || params.dig(:builder, :template) || 'index'
  170. begin
  171. Rails.logger.info "Save draft params: #{params.inspect}"
  172. Rails.logger.info "Sections data: #{sections_data.inspect}"
  173. Rails.logger.info "Settings data: #{settings_data.inspect}"
  174. # Get or create theme preview for this template
  175. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  176. # Update sections in ThemePreview
  177. if sections_data.present?
  178. # Get existing sections for efficient updates
  179. existing_sections = theme_preview.theme_preview_sections.index_by(&:section_id)
  180. processed_section_ids = []
  181. # Create or update sections from the data
  182. sections_data.each_with_index do |(section_id, section_config), index|
  183. processed_section_ids << section_id
  184. if existing_sections[section_id]
  185. # Update existing section
  186. existing_sections[section_id].update!(
  187. section_type: section_config['type'] || section_id,
  188. settings: section_config['settings'] || {},
  189. position: index
  190. )
  191. else
  192. # Create new section
  193. theme_preview.theme_preview_sections.create!(
  194. section_id: section_id,
  195. section_type: section_config['type'] || section_id,
  196. settings: section_config['settings'] || {},
  197. position: index
  198. )
  199. end
  200. end
  201. # Remove sections that are no longer in the data
  202. sections_to_remove = existing_sections.keys - processed_section_ids
  203. sections_to_remove.each do |section_id|
  204. existing_sections[section_id]&.destroy!
  205. end
  206. end
  207. # Update theme settings in ThemePreviewFile
  208. if settings_data.present?
  209. ThemePreviewFile.update_template_content(
  210. @builder_theme,
  211. template,
  212. settings_data
  213. )
  214. end
  215. # Update individual files in ThemePreviewFile if provided
  216. if params[:files].present?
  217. params[:files].each do |file_path, content|
  218. preview_file = @builder_theme.theme_preview_files.find_or_create_by(
  219. file_path: file_path,
  220. file_type: 'custom'
  221. ) do |file|
  222. file.tenant = @builder_theme.tenant
  223. end
  224. preview_file.update!(content: content)
  225. end
  226. end
  227. # Broadcast update to preview
  228. broadcast_preview_update(@builder_theme)
  229. respond_to do |format|
  230. format.json { render json: { success: true, message: 'Draft saved to preview successfully!' } }
  231. end
  232. rescue => e
  233. Rails.logger.error "Save draft failed: #{e.message}"
  234. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  235. Rails.logger.error "Params: #{params.inspect}"
  236. respond_to do |format|
  237. format.json { render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity }
  238. end
  239. end
  240. end
  241. # PATCH /admin/builder/:id/publish
  242. def publish
  243. template = params[:template] || params.dig(:builder, :template) || 'index'
  244. begin
  245. Rails.logger.info "Publishing template: #{template}"
  246. # Get the theme preview
  247. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  248. # Ensure we have a published version to work with
  249. published_version = @builder_theme.ensure_published_version!
  250. # Copy sections from ThemePreview to PublishedThemeFile
  251. sections_data = {}
  252. section_order = []
  253. theme_preview.ordered_sections.each do |section|
  254. sections_data[section.section_id] = {
  255. 'type' => section.section_type,
  256. 'settings' => section.settings
  257. }
  258. section_order << section.section_id
  259. end
  260. # Create/update the template file in PublishedThemeFile
  261. template_content = {
  262. 'name' => template.humanize,
  263. 'sections' => sections_data,
  264. 'order' => section_order
  265. }
  266. template_file = published_version.published_theme_files.find_or_create_by(
  267. file_path: "templates/#{template}.json",
  268. file_type: 'template'
  269. )
  270. template_file.update!(
  271. content: template_content.to_json,
  272. checksum: Digest::MD5.hexdigest(template_content.to_json)
  273. )
  274. # Copy theme settings if they exist in preview
  275. settings_file = @builder_theme.theme_preview_files.find_by(
  276. file_path: 'config/settings_data.json'
  277. )
  278. if settings_file
  279. published_settings_file = published_version.published_theme_files.find_or_create_by(
  280. file_path: 'config/settings_data.json',
  281. file_type: 'config'
  282. )
  283. published_settings_file.update!(
  284. content: settings_file.content,
  285. checksum: Digest::MD5.hexdigest(settings_file.content)
  286. )
  287. end
  288. # Copy any other custom files from preview to published
  289. @builder_theme.theme_preview_files.where.not(
  290. file_path: ['config/settings_data.json', "templates/#{template}.json"]
  291. ).each do |preview_file|
  292. published_file = published_version.published_theme_files.find_or_create_by(
  293. file_path: preview_file.file_path,
  294. file_type: preview_file.file_type
  295. )
  296. published_file.update!(
  297. content: preview_file.content,
  298. checksum: Digest::MD5.hexdigest(preview_file.content)
  299. )
  300. end
  301. # Mark the builder theme as published
  302. @builder_theme.update!(published: true)
  303. Rails.logger.info "Successfully published template: #{template}"
  304. respond_to do |format|
  305. format.json { render json: { success: true, message: 'Theme published successfully!' } }
  306. end
  307. rescue => e
  308. Rails.logger.error "Publish failed: #{e.message}"
  309. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  310. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  311. end
  312. end
  313. # POST /admin/builder/:id/rollback
  314. def rollback
  315. target_snapshot_id = params[:snapshot_id]
  316. target_snapshot = BuilderThemeSnapshot.find(target_snapshot_id)
  317. new_version = target_snapshot.rollback_to!(target_snapshot)
  318. respond_to do |format|
  319. format.json { render json: { success: true, version_id: new_version.id, redirect_url: admin_builder_path(new_version) } }
  320. format.html { redirect_to admin_builder_path(new_version), notice: 'Rolled back successfully!' }
  321. end
  322. end
  323. # GET /admin/builder/:id/preview
  324. def preview
  325. @builder_theme = BuilderTheme.find(params[:id])
  326. @current_theme_name = @builder_theme.theme_name
  327. # Ensure we have a published version to work with for base files (layout, assets)
  328. published_version = @builder_theme.ensure_published_version!
  329. template_type = params[:template] || 'index'
  330. begin
  331. # Use ThemePreviewRenderer for builder previews (uses ThemePreview data + PublishedThemeFile base files)
  332. renderer = ThemePreviewRenderer.new(@builder_theme, template_type)
  333. @preview_html = renderer.render
  334. @assets = { css: '', js: '' } # Assets are embedded in HTML by ThemePreviewRenderer
  335. rescue => e
  336. Rails.logger.error "Builder preview rendering failed: #{e.message}"
  337. @preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
  338. @assets = { css: '', js: '' }
  339. end
  340. # Render preview iframe
  341. render 'preview', layout: false
  342. end
  343. # GET /admin/builder/:id/sections/:template
  344. def sections
  345. template_name = params[:template]
  346. begin
  347. # Get or create theme preview for this template
  348. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template_name)
  349. # Get sections from ThemePreview
  350. sections = theme_preview.ordered_sections.map do |section|
  351. {
  352. section_id: section.section_id,
  353. section_type: section.section_type,
  354. settings: section.settings,
  355. position: section.position
  356. }
  357. end
  358. render json: { success: true, sections: sections }
  359. rescue => e
  360. Rails.logger.error "Error getting sections: #{e.message}"
  361. render json: { success: false, errors: [e.message] }, status: :unprocessable_entity
  362. end
  363. end
  364. # GET /admin/builder/:id/available_sections
  365. def available_sections
  366. begin
  367. # Get available sections from the BuilderTheme's theme directory
  368. @builder_theme = BuilderTheme.find(params[:id])
  369. # Get the theme name from the BuilderTheme
  370. theme_name = @builder_theme.theme_name
  371. # Get sections directory from the theme
  372. manager = ThemesManager.new
  373. sections_dir = File.join(manager.themes_path, theme_name, 'sections')
  374. if Dir.exist?(sections_dir)
  375. sections = []
  376. processed_sections = Set.new
  377. # First, process .liquid files
  378. Dir.glob(File.join(sections_dir, '*.liquid')).each do |file_path|
  379. section_name = File.basename(file_path, '.liquid')
  380. section_name = section_name.to_s
  381. # Skip if we've already processed this section
  382. next if processed_sections.include?(section_name)
  383. processed_sections.add(section_name)
  384. # Try to read section schema for description
  385. schema_path = File.join(sections_dir, "#{section_name}.json")
  386. description = 'Section description'
  387. category = 'General'
  388. preview_image = nil
  389. if File.exist?(schema_path)
  390. begin
  391. schema = JSON.parse(File.read(schema_path))
  392. description = schema['description'] || 'Section description'
  393. category = schema['category'] || 'General'
  394. preview_image = schema['preview_image']
  395. rescue JSON::ParserError
  396. # Use default description if schema is invalid
  397. end
  398. end
  399. # Extract context requests from schema
  400. context_requests = {}
  401. if File.exist?(schema_path)
  402. begin
  403. schema = JSON.parse(File.read(schema_path))
  404. context_requests = schema['context_requests'] || {}
  405. rescue JSON::ParserError
  406. # Use empty context requests if schema is invalid
  407. end
  408. end
  409. sections << {
  410. id: section_name,
  411. name: section_name.humanize,
  412. description: description,
  413. category: category,
  414. preview_image: preview_image,
  415. context_requests: context_requests
  416. }
  417. end
  418. # Then, process standalone .json files (sections without .liquid files)
  419. Dir.glob(File.join(sections_dir, '*.json')).each do |file_path|
  420. section_name = File.basename(file_path, '.json')
  421. section_name = section_name.to_s
  422. # Skip if we've already processed this section
  423. next if processed_sections.include?(section_name)
  424. processed_sections.add(section_name)
  425. begin
  426. schema = JSON.parse(File.read(file_path))
  427. sections << {
  428. id: section_name,
  429. name: section_name.humanize,
  430. description: schema['description'] || 'Section description',
  431. category: schema['category'] || 'General',
  432. preview_image: schema['preview_image'],
  433. context_requests: schema['context_requests'] || {}
  434. }
  435. rescue JSON::ParserError
  436. # Skip invalid JSON files
  437. next
  438. end
  439. end
  440. # Add context data for sections that request it
  441. sections_with_context = sections.map do |section|
  442. if section[:context_requests].present?
  443. section[:context_data] = get_context_data_for_section(section[:context_requests])
  444. end
  445. section
  446. end
  447. render json: { success: true, sections: sections_with_context }
  448. else
  449. render json: { success: false, errors: ['Sections directory not found'] }, status: :not_found
  450. end
  451. rescue => e
  452. Rails.logger.error "Error loading available sections: #{e.message}"
  453. render json: { success: false, errors: [e.message] }, status: :internal_server_error
  454. end
  455. end
  456. # GET /admin/builder/:id/section_data
  457. def section_data
  458. begin
  459. @builder_theme = BuilderTheme.find(params[:id])
  460. section_type = params[:section_type]
  461. # Get section schema
  462. theme_name = @builder_theme.theme_name
  463. manager = ThemesManager.new
  464. schema_path = File.join(manager.themes_path, theme_name, 'sections', "#{section_type}.json")
  465. schema = {}
  466. if File.exist?(schema_path)
  467. schema = JSON.parse(File.read(schema_path))
  468. end
  469. # Get context data for this section
  470. context_data = get_context_data_for_section(schema['context_requests'] || {})
  471. render json: {
  472. success: true,
  473. schema: schema,
  474. context_data: context_data
  475. }
  476. rescue => e
  477. Rails.logger.error "Error loading section data: #{e.message}"
  478. render json: { success: false, errors: [e.message] }, status: :internal_server_error
  479. end
  480. end
  481. def get_context_data_for_section(context_requests)
  482. context_data = {}
  483. context_requests.each do |key, request_config|
  484. case key
  485. when 'menus'
  486. context_data[key] = get_menus_context
  487. when 'pages'
  488. context_data[key] = get_pages_context
  489. when 'posts'
  490. context_data[key] = get_posts_context
  491. when 'categories'
  492. context_data[key] = get_categories_context
  493. when 'products'
  494. context_data[key] = get_products_context
  495. else
  496. Rails.logger.warn "Unknown context request: #{key}"
  497. end
  498. end
  499. context_data
  500. end
  501. def get_menus_context
  502. # Return available menus for navigation
  503. [
  504. {
  505. id: 1,
  506. name: 'Main Navigation',
  507. menu_items: [
  508. { id: 1, title: 'Home', url: '/', order: 1 },
  509. { id: 2, title: 'About', url: '/about', order: 2 },
  510. { id: 3, title: 'Services', url: '/services', order: 3 },
  511. { id: 4, title: 'Contact', url: '/contact', order: 4 }
  512. ]
  513. },
  514. {
  515. id: 2,
  516. name: 'Footer Links',
  517. menu_items: [
  518. { id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
  519. { id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
  520. { id: 7, title: 'Support', url: '/support', order: 3 }
  521. ]
  522. }
  523. ]
  524. end
  525. def get_pages_context
  526. # Return available pages
  527. [
  528. { id: 1, title: 'Home', slug: 'home', url: '/' },
  529. { id: 2, title: 'About Us', slug: 'about', url: '/about' },
  530. { id: 3, title: 'Services', slug: 'services', url: '/services' },
  531. { id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
  532. { id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
  533. ]
  534. end
  535. def get_posts_context
  536. # Return recent posts
  537. [
  538. { id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
  539. { id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
  540. ]
  541. end
  542. def get_categories_context
  543. # Return post categories
  544. [
  545. { id: 1, name: 'News', slug: 'news' },
  546. { id: 2, name: 'Tutorials', slug: 'tutorials' },
  547. { id: 3, name: 'Updates', slug: 'updates' }
  548. ]
  549. end
  550. def get_products_context
  551. # Return sample products (for e-commerce sections)
  552. [
  553. { id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
  554. { id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
  555. ]
  556. end
  557. # GET /admin/builder/:id/file/:file_path
  558. def get_file
  559. @builder_theme = BuilderTheme.find(params[:id])
  560. file_path = params[:file_path]
  561. file = @builder_theme.get_file(file_path)
  562. if file
  563. render json: {
  564. success: true,
  565. file: {
  566. path: file.path,
  567. content: file.content,
  568. file_type: file.file_type,
  569. schema: file.schema_data
  570. }
  571. }
  572. else
  573. render json: { success: false, error: 'File not found' }, status: :not_found
  574. end
  575. end
  576. # PATCH /admin/builder/:id/file/:file_path
  577. def update_file
  578. @builder_theme = BuilderTheme.find(params[:id])
  579. file_path = params[:file_path]
  580. content = params[:content]
  581. file = @builder_theme.update_file(file_path, content)
  582. # Broadcast update to preview
  583. broadcast_preview_update(@builder_theme)
  584. render json: {
  585. success: true,
  586. file: {
  587. path: file.path,
  588. content: file.content,
  589. checksum: file.checksum,
  590. file_size: file.file_size
  591. }
  592. }
  593. end
  594. # GET /admin/builder/:id/render_preview
  595. def render_preview
  596. @builder_theme = BuilderTheme.find(params[:id])
  597. template_type = params[:template] || 'index'
  598. renderer = BuilderLiquidRenderer.new(@builder_theme)
  599. preview_html = renderer.render_preview(template_type)
  600. render json: {
  601. success: true,
  602. html: preview_html,
  603. template: template_type
  604. }
  605. end
  606. # POST /admin/builder/:id/add_section
  607. def add_section
  608. section_type = params[:section_type]
  609. # Handle both string and ActionController::Parameters for settings
  610. raw_settings = params[:settings] || params.dig(:builder, :settings) || {}
  611. settings = case raw_settings
  612. when String
  613. JSON.parse(raw_settings)
  614. when ActionController::Parameters
  615. raw_settings.to_unsafe_h
  616. else
  617. raw_settings || {}
  618. end
  619. # Ensure settings is always a Hash (not nil) to satisfy the NOT NULL constraint
  620. settings = {} if settings.nil?
  621. template = params[:template] || 'index'
  622. begin
  623. Rails.logger.info "Add section params: #{params.inspect}"
  624. Rails.logger.info "Section type: #{section_type}, Template: #{template}"
  625. if section_type.blank?
  626. return render json: { success: false, errors: ['Section type is required'] }, status: :bad_request
  627. end
  628. # Get or create theme preview (without auto-initialization to avoid clearing sections)
  629. theme_preview = ThemePreview.find_or_create_by(
  630. builder_theme: @builder_theme,
  631. template_name: template
  632. ) do |preview|
  633. preview.tenant = @builder_theme.tenant
  634. end
  635. # Generate a unique section ID
  636. section_id = "#{section_type}_#{SecureRandom.hex(4)}"
  637. # Create new section in ThemePreviewSection
  638. section = theme_preview.theme_preview_sections.create!(
  639. section_id: section_id,
  640. section_type: section_type,
  641. settings: settings,
  642. position: theme_preview.theme_preview_sections.count
  643. )
  644. Rails.logger.info "Successfully added section #{section_id} (#{section_type}) to template #{template}"
  645. respond_to do |format|
  646. format.json { render json: {
  647. success: true,
  648. section: {
  649. section_id: section.section_id,
  650. section_type: section.section_type,
  651. settings: section.settings,
  652. position: section.position
  653. }
  654. } }
  655. end
  656. rescue => e
  657. Rails.logger.error "Add section failed: #{e.message}"
  658. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  659. Rails.logger.error "Params: #{params.inspect}"
  660. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  661. end
  662. end
  663. # DELETE /admin/builder/:id/remove_section/:section_id
  664. def remove_section
  665. section_id = params[:section_id]
  666. template = params[:template] || 'index'
  667. begin
  668. Rails.logger.info "Remove section params: #{params.inspect}"
  669. Rails.logger.info "Section ID: #{section_id}, Template: #{template}"
  670. if section_id.blank?
  671. return render json: { success: false, errors: ['Section ID is required'] }, status: :bad_request
  672. end
  673. # Get or create theme preview
  674. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  675. # Find and remove the section from ThemePreviewSection
  676. section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
  677. if section
  678. section.destroy!
  679. Rails.logger.info "Successfully removed section #{section_id} from template #{template}"
  680. render json: { success: true, message: 'Section removed successfully!' }
  681. else
  682. Rails.logger.warn "Section #{section_id} not found in template #{template}"
  683. render json: { success: false, errors: ['Section not found'] }, status: :not_found
  684. end
  685. rescue => e
  686. Rails.logger.error "Remove section failed: #{e.message}"
  687. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  688. Rails.logger.error "Params: #{params.inspect}"
  689. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  690. end
  691. end
  692. # POST /admin/builder/:id/add_block
  693. def add_block
  694. section_id = params[:section_id]
  695. block_type = params[:block_type]
  696. block_id = params[:block_id]
  697. settings = case params[:settings]
  698. when String
  699. JSON.parse(params[:settings])
  700. when ActionController::Parameters
  701. params[:settings].to_unsafe_h
  702. else
  703. params[:settings] || {}
  704. end
  705. template = params[:template] || 'index'
  706. begin
  707. Rails.logger.info "Add block params: #{params.inspect}"
  708. Rails.logger.info "Section ID: #{section_id}, Block Type: #{block_type}, Block ID: #{block_id}"
  709. if section_id.blank? || block_type.blank? || block_id.blank?
  710. return render json: { success: false, errors: ['Section ID, block type, and block ID are required'] }, status: :bad_request
  711. end
  712. # Get or create theme preview
  713. theme_preview = ThemePreview.find_or_create_by(
  714. builder_theme: @builder_theme,
  715. template_name: template
  716. ) do |preview|
  717. preview.tenant = @builder_theme.tenant
  718. end
  719. # Find the section
  720. section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
  721. if !section
  722. return render json: { success: false, errors: ['Section not found'] }, status: :not_found
  723. end
  724. # Create new block
  725. block = section.theme_preview_blocks.create!(
  726. block_id: block_id,
  727. block_type: block_type,
  728. settings: settings,
  729. position: section.theme_preview_blocks.count
  730. )
  731. Rails.logger.info "Successfully added block #{block_id} (#{block_type}) to section #{section_id}"
  732. respond_to do |format|
  733. format.json { render json: {
  734. success: true,
  735. block: {
  736. block_id: block.block_id,
  737. block_type: block.block_type,
  738. settings: block.settings,
  739. position: block.position
  740. }
  741. } }
  742. end
  743. rescue => e
  744. Rails.logger.error "Add block failed: #{e.message}"
  745. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  746. Rails.logger.error "Params: #{params.inspect}"
  747. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  748. end
  749. end
  750. # DELETE /admin/builder/:id/remove_block/:block_id
  751. def remove_block
  752. block_id = params[:block_id]
  753. section_id = params[:section_id]
  754. template = params[:template] || 'index'
  755. begin
  756. Rails.logger.info "Remove block params: #{params.inspect}"
  757. Rails.logger.info "Block ID: #{block_id}, Section ID: #{section_id}"
  758. if block_id.blank?
  759. return render json: { success: false, errors: ['Block ID is required'] }, status: :bad_request
  760. end
  761. # Get theme preview
  762. theme_preview = ThemePreview.find_or_create_by(
  763. builder_theme: @builder_theme,
  764. template_name: template
  765. ) do |preview|
  766. preview.tenant = @builder_theme.tenant
  767. end
  768. # Find the section and block
  769. section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
  770. if !section
  771. return render json: { success: false, errors: ['Section not found'] }, status: :not_found
  772. end
  773. block = section.theme_preview_blocks.find_by(block_id: block_id)
  774. if block
  775. block.destroy!
  776. Rails.logger.info "Successfully removed block #{block_id} from section #{section_id}"
  777. render json: { success: true, message: 'Block removed successfully!' }
  778. else
  779. Rails.logger.warn "Block #{block_id} not found in section #{section_id}"
  780. render json: { success: false, errors: ['Block not found'] }, status: :not_found
  781. end
  782. rescue => e
  783. Rails.logger.error "Remove block failed: #{e.message}"
  784. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  785. Rails.logger.error "Params: #{params.inspect}"
  786. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  787. end
  788. end
  789. # PATCH /admin/builder/:id/update_block/:block_id
  790. def update_block
  791. block_id = params[:block_id]
  792. section_id = params[:section_id]
  793. template = params[:template] || 'index'
  794. settings = case params[:settings]
  795. when String
  796. JSON.parse(params[:settings])
  797. when ActionController::Parameters
  798. params[:settings].to_unsafe_h
  799. else
  800. params[:settings] || {}
  801. end
  802. begin
  803. Rails.logger.info "Update block params: #{params.inspect}"
  804. Rails.logger.info "Block ID: #{block_id}, Section ID: #{section_id}, Settings: #{settings.inspect}"
  805. if block_id.blank?
  806. return render json: { success: false, errors: ['Block ID is required'] }, status: :bad_request
  807. end
  808. # Get theme preview
  809. theme_preview = ThemePreview.find_or_create_by(
  810. builder_theme: @builder_theme,
  811. template_name: template
  812. ) do |preview|
  813. preview.tenant = @builder_theme.tenant
  814. end
  815. # Find the section and block
  816. section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
  817. if !section
  818. return render json: { success: false, errors: ['Section not found'] }, status: :not_found
  819. end
  820. block = section.theme_preview_blocks.find_by(block_id: block_id)
  821. if !block
  822. return render json: { success: false, errors: ['Block not found'] }, status: :not_found
  823. end
  824. # Update the block settings
  825. block.update!(settings: settings)
  826. Rails.logger.info "Successfully updated block #{block_id} for section #{section_id}"
  827. render json: {
  828. success: true,
  829. message: 'Block updated successfully!',
  830. block_id: block_id,
  831. updated_settings: settings
  832. }
  833. rescue JSON::ParserError => e
  834. Rails.logger.error "JSON parsing error in update_block: #{e.message}"
  835. render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
  836. rescue => e
  837. Rails.logger.error "Update block failed: #{e.message}"
  838. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  839. Rails.logger.error "Params: #{params.inspect}"
  840. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  841. end
  842. end
  843. # PATCH /admin/builder/:id/update_theme_settings
  844. def update_theme_settings
  845. template = params[:template] || 'index'
  846. settings = case params[:settings]
  847. when String
  848. JSON.parse(params[:settings])
  849. when ActionController::Parameters
  850. params[:settings].to_unsafe_h
  851. else
  852. params[:settings] || {}
  853. end
  854. begin
  855. Rails.logger.info "Update theme settings params: #{params.inspect}"
  856. Rails.logger.info "Template: #{template}, Settings: #{settings.inspect}"
  857. # Get or create theme preview
  858. theme_preview = ThemePreview.find_or_create_by(
  859. builder_theme: @builder_theme,
  860. template_name: template
  861. ) do |preview|
  862. preview.tenant = @builder_theme.tenant
  863. end
  864. # Update theme settings
  865. theme_preview.update!(theme_settings_json: settings)
  866. Rails.logger.info "Successfully updated theme settings for template #{template}"
  867. render json: {
  868. success: true,
  869. message: 'Theme settings updated successfully!',
  870. template: template,
  871. updated_settings: settings
  872. }
  873. rescue JSON::ParserError => e
  874. Rails.logger.error "JSON parsing error in update_theme_settings: #{e.message}"
  875. render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
  876. rescue => e
  877. Rails.logger.error "Update theme settings failed: #{e.message}"
  878. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  879. Rails.logger.error "Params: #{params.inspect}"
  880. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  881. end
  882. end
  883. # PATCH /admin/builder/:id/update_section/:section_id
  884. def update_section
  885. section_id = params[:section_id]
  886. template = params[:template] || params.dig(:builder, :template) || 'index'
  887. # params[:settings] can arrive as a Hash (from JSON) or a String
  888. # Handle both direct params and nested builder params
  889. raw_settings = params[:settings] || params.dig(:builder, :settings)
  890. settings = case raw_settings
  891. when String
  892. JSON.parse(raw_settings)
  893. when ActionController::Parameters
  894. raw_settings.to_unsafe_h
  895. else
  896. raw_settings || {}
  897. end
  898. begin
  899. Rails.logger.info "Update section params: #{params.inspect}"
  900. Rails.logger.info "Section ID: #{section_id}, Template: #{template}, Settings: #{settings.inspect}"
  901. # Validate section_id is present
  902. if section_id.blank?
  903. return render json: { success: false, errors: ['Section ID is required'] }, status: :bad_request
  904. end
  905. # Use ThemePreview for builder previews (separate from published themes)
  906. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  907. # Update the section settings
  908. theme_preview.update_section_settings(section_id, settings)
  909. Rails.logger.info "Successfully updated section #{section_id} for template #{template}"
  910. render json: {
  911. success: true,
  912. message: 'Section updated successfully!',
  913. section_id: section_id,
  914. updated_settings: settings
  915. }
  916. rescue JSON::ParserError => e
  917. Rails.logger.error "JSON parsing error in update_section: #{e.message}"
  918. render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
  919. rescue => e
  920. Rails.logger.error "Update section failed: #{e.message}"
  921. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  922. Rails.logger.error "Params: #{params.inspect}"
  923. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  924. end
  925. end
  926. # PATCH /admin/builder/:id/reorder_sections
  927. def reorder_sections
  928. begin
  929. # Handle both array and JSON string parameters
  930. raw_section_ids = params[:section_ids] || params.dig(:builder, :section_ids) || []
  931. if raw_section_ids.is_a?(String)
  932. section_ids = JSON.parse(raw_section_ids)
  933. else
  934. section_ids = raw_section_ids
  935. end
  936. template = params[:template] || params.dig(:builder, :template) || 'index'
  937. Rails.logger.info "=== REORDER SECTIONS DEBUG ==="
  938. Rails.logger.info "Raw section IDs: #{raw_section_ids.inspect}"
  939. Rails.logger.info "Processed section IDs: #{section_ids.inspect}"
  940. Rails.logger.info "Template: #{template}"
  941. # Validate that we have section IDs
  942. if section_ids.blank?
  943. return render json: { success: false, errors: ['No section IDs provided'] }, status: :bad_request
  944. end
  945. # Use ThemePreview for builder previews (separate from published themes)
  946. theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
  947. # Validate that all section IDs exist in the preview
  948. existing_section_ids = theme_preview.theme_preview_sections.pluck(:section_id)
  949. invalid_section_ids = section_ids - existing_section_ids
  950. if invalid_section_ids.any?
  951. Rails.logger.error "Invalid section IDs provided: #{invalid_section_ids.inspect}"
  952. Rails.logger.error "Existing section IDs: #{existing_section_ids.inspect}"
  953. return render json: {
  954. success: false,
  955. errors: ["Invalid section IDs: #{invalid_section_ids.join(', ')}"]
  956. }, status: :bad_request
  957. end
  958. # Update the section order
  959. theme_preview.update_section_order(section_ids)
  960. Rails.logger.info "Successfully reordered sections for template: #{template}"
  961. respond_to do |format|
  962. format.json { render json: {
  963. success: true,
  964. message: 'Sections reordered successfully!',
  965. section_ids: section_ids
  966. } }
  967. end
  968. rescue JSON::ParserError => e
  969. Rails.logger.error "JSON parsing error in reorder_sections: #{e.message}"
  970. render json: { success: false, errors: ['Invalid JSON in section IDs'] }, status: :bad_request
  971. rescue => e
  972. Rails.logger.error "Reorder sections failed: #{e.message}"
  973. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  974. render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
  975. end
  976. end
  977. def format_section_data(section)
  978. {
  979. id: section.section_id,
  980. type: section.section_type,
  981. settings: section.settings,
  982. position: section.position,
  983. display_name: section.display_name,
  984. description: section.description
  985. }
  986. end
  987. # GET /admin/builder/:id/versions
  988. def versions
  989. @builder_theme = BuilderTheme.find(params[:id])
  990. @versions = BuilderTheme.for_theme(@builder_theme.theme_name).includes(:user).latest
  991. render json: {
  992. versions: @versions.map do |version|
  993. {
  994. id: version.id,
  995. label: version.label,
  996. created_at: version.created_at,
  997. created_by: version.user.email,
  998. published: version.published?,
  999. version_number: version.version_number
  1000. }
  1001. end
  1002. }
  1003. end
  1004. # GET /admin/builder/:id/snapshots
  1005. def snapshots
  1006. @builder_theme = BuilderTheme.find(params[:id])
  1007. @snapshots = BuilderThemeSnapshot.for_theme(@builder_theme.theme_name).includes(:user).latest
  1008. render json: {
  1009. snapshots: @snapshots.map do |snapshot|
  1010. {
  1011. id: snapshot.id,
  1012. created_at: snapshot.created_at,
  1013. created_by: snapshot.user.email,
  1014. checksum: snapshot.checksum
  1015. }
  1016. end
  1017. }
  1018. end
  1019. def set_current_theme
  1020. # Allow editing any theme, not just active ones
  1021. if params[:theme_id].present?
  1022. @current_theme = Theme.find(params[:theme_id])
  1023. elsif params[:theme_name].present?
  1024. @current_theme = Theme.where("LOWER(name) = ?", params[:theme_name].downcase).first
  1025. else
  1026. # Fallback to active theme
  1027. @current_theme = Theme.active.first
  1028. end
  1029. end
  1030. def preview_context
  1031. {
  1032. current_user: current_user,
  1033. request: request
  1034. }
  1035. end
  1036. def set_builder_theme
  1037. Rails.logger.info "Looking for BuilderTheme with ID: #{params[:id]}"
  1038. @builder_theme = BuilderTheme.find_by(id: params[:id])
  1039. unless @builder_theme
  1040. Rails.logger.error "BuilderTheme not found with ID: #{params[:id]}"
  1041. Rails.logger.info "Available BuilderThemes: #{BuilderTheme.pluck(:id, :label)}"
  1042. render json: { success: false, errors: ['Builder theme not found'] }, status: :not_found
  1043. return
  1044. end
  1045. Rails.logger.info "Found BuilderTheme: #{@builder_theme.inspect}"
  1046. end
  1047. def get_available_templates
  1048. # Use ThemesManager to get routes from the selected theme
  1049. manager = ThemesManager.new
  1050. begin
  1051. # Get routes from the current theme (active or selected)
  1052. theme_name = @current_theme&.name&.underscore || 'default'
  1053. routes_data = manager.get_file("config/routes.json", theme_name)
  1054. routes = routes_data['routes'] || []
  1055. # Convert to the format expected by the view
  1056. routes.map do |route|
  1057. {
  1058. 'name' => route['name'] || route['template'].humanize,
  1059. 'template' => route['template'],
  1060. 'path' => route['pattern']
  1061. }
  1062. end
  1063. rescue => e
  1064. Rails.logger.error "Error loading routes from ThemesManager: #{e.message}"
  1065. fallback_templates
  1066. end
  1067. end
  1068. private
  1069. def get_context_data_for_section(context_requests)
  1070. context_data = {}
  1071. context_requests.each do |key, request_config|
  1072. case key
  1073. when 'menus'
  1074. context_data[key] = get_menus_context
  1075. when 'pages'
  1076. context_data[key] = get_pages_context
  1077. when 'posts'
  1078. context_data[key] = get_posts_context
  1079. when 'categories'
  1080. context_data[key] = get_categories_context
  1081. when 'products'
  1082. context_data[key] = get_products_context
  1083. else
  1084. Rails.logger.warn "Unknown context request: #{key}"
  1085. end
  1086. end
  1087. context_data
  1088. end
  1089. def get_menus_context
  1090. # Return available menus for navigation
  1091. [
  1092. {
  1093. id: 1,
  1094. name: 'Main Navigation',
  1095. menu_items: [
  1096. { id: 1, title: 'Home', url: '/', order: 1 },
  1097. { id: 2, title: 'About', url: '/about', order: 2 },
  1098. { id: 3, title: 'Services', url: '/services', order: 3 },
  1099. { id: 4, title: 'Contact', url: '/contact', order: 4 }
  1100. ]
  1101. },
  1102. {
  1103. id: 2,
  1104. name: 'Footer Links',
  1105. menu_items: [
  1106. { id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
  1107. { id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
  1108. { id: 7, title: 'Support', url: '/support', order: 3 }
  1109. ]
  1110. }
  1111. ]
  1112. end
  1113. def get_pages_context
  1114. # Return available pages
  1115. [
  1116. { id: 1, title: 'Home', slug: 'home', url: '/' },
  1117. { id: 2, title: 'About Us', slug: 'about', url: '/about' },
  1118. { id: 3, title: 'Services', slug: 'services', url: '/services' },
  1119. { id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
  1120. { id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
  1121. ]
  1122. end
  1123. def get_posts_context
  1124. # Return recent posts
  1125. [
  1126. { id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
  1127. { id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
  1128. ]
  1129. end
  1130. def get_categories_context
  1131. # Return post categories
  1132. [
  1133. { id: 1, name: 'News', slug: 'news' },
  1134. { id: 2, name: 'Tutorials', slug: 'tutorials' },
  1135. { id: 3, name: 'Updates', slug: 'updates' }
  1136. ]
  1137. end
  1138. def get_products_context
  1139. # Return sample products (for e-commerce sections)
  1140. [
  1141. { id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
  1142. { id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
  1143. ]
  1144. end
  1145. def format_section_data(section)
  1146. {
  1147. id: section.section_id,
  1148. type: section.section_type,
  1149. settings: section.settings,
  1150. position: section.position,
  1151. created_at: section.created_at,
  1152. updated_at: section.updated_at
  1153. }
  1154. end
  1155. def set_current_theme
  1156. # Allow editing any theme, not just active ones
  1157. if params[:theme_id].present?
  1158. @current_theme = Theme.find(params[:theme_id])
  1159. elsif params[:theme_name].present?
  1160. @current_theme = Theme.where("LOWER(name) = ?", params[:theme_name].downcase).first
  1161. else
  1162. @current_theme = Theme.active.first || Theme.first
  1163. end
  1164. end
  1165. def set_builder_theme
  1166. @builder_theme = BuilderTheme.find(params[:id])
  1167. end
  1168. def fallback_templates
  1169. # Fallback to default templates
  1170. [
  1171. { 'name' => 'Home', 'template' => 'index', 'path' => '/' },
  1172. { 'name' => 'Blog', 'template' => 'blog', 'path' => '/blog' },
  1173. { 'name' => 'Post', 'template' => 'post', 'path' => '/post' },
  1174. { 'name' => 'Page', 'template' => 'page', 'path' => '/page' },
  1175. { 'name' => 'Search', 'template' => 'search', 'path' => '/search' },
  1176. { 'name' => '404', 'template' => '404', 'path' => '/404' }
  1177. ]
  1178. end
  1179. def load_theme_schema
  1180. # Load theme settings schema from config/settings_schema.json using ThemesManager
  1181. manager = ThemesManager.new
  1182. settings_content = manager.get_file('config/settings_schema.json')
  1183. return [] unless settings_content
  1184. begin
  1185. JSON.parse(settings_content)
  1186. rescue JSON::ParserError
  1187. []
  1188. end
  1189. end
  1190. def broadcast_preview_update(builder_theme)
  1191. # Broadcast to ActionCable channel for live preview updates
  1192. ActionCable.server.broadcast(
  1193. "builder_preview_#{builder_theme.id}",
  1194. {
  1195. type: 'preview_update',
  1196. theme_id: builder_theme.id,
  1197. timestamp: Time.current.to_i
  1198. }
  1199. )
  1200. end
  1201. def builder_theme_params
  1202. params.require(:builder_theme).permit(:label, :summary)
  1203. end
  1204. end

app/controllers/admin/bulk_optimization_controller.rb

0.0% lines covered

100.0% branches covered

205 relevant lines. 0 lines covered and 205 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::BulkOptimizationController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/media/bulk_optimization
  4. def index
  5. @stats = calculate_optimization_stats
  6. @unoptimized_count = count_unoptimized_images
  7. # Load compression level information
  8. compression_level_name = SiteSetting.get('image_compression_level', 'lossy')
  9. compression_config = ImageOptimizationService.available_compression_levels[compression_level_name] || ImageOptimizationService.available_compression_levels['lossy']
  10. @compression_level_name = compression_config[:name]
  11. @compression_description = compression_config[:description]
  12. @expected_savings = compression_config[:expected_savings]
  13. @recommended_for = compression_config[:recommended_for]
  14. end
  15. # POST /admin/media/bulk_optimize
  16. def start_bulk_optimization
  17. # Get all unoptimized images
  18. unoptimized_uploads = get_unoptimized_uploads
  19. if unoptimized_uploads.empty?
  20. render json: {
  21. success: false,
  22. message: 'No unoptimized images found'
  23. }
  24. return
  25. end
  26. # Queue optimization jobs
  27. job_count = 0
  28. unoptimized_uploads.find_each do |upload|
  29. upload.media.each do |medium|
  30. OptimizeImageJob.perform_later(medium_id: medium.id)
  31. job_count += 1
  32. end
  33. end
  34. # Store job tracking info
  35. Rails.cache.write('bulk_optimization_jobs', job_count, expires_in: 1.hour)
  36. Rails.cache.write('bulk_optimization_started', Time.current, expires_in: 1.hour)
  37. render json: {
  38. success: true,
  39. message: "Queued #{job_count} images for optimization",
  40. total_jobs: job_count
  41. }
  42. end
  43. # GET /admin/media/bulk_optimize_status
  44. def status
  45. total_jobs = Rails.cache.read('bulk_optimization_jobs') || 0
  46. started_at = Rails.cache.read('bulk_optimization_started')
  47. if total_jobs == 0
  48. render json: {
  49. percentage: 100,
  50. message: 'No optimization jobs running',
  51. completed: true
  52. }
  53. return
  54. end
  55. # Calculate progress based on completed optimizations
  56. completed_count = count_optimized_images
  57. percentage = total_jobs > 0 ? ((completed_count.to_f / total_jobs) * 100).round(1) : 0
  58. message = if percentage >= 100
  59. 'Optimization complete!'
  60. elsif percentage > 0
  61. "Optimizing images... #{completed_count}/#{total_jobs} completed"
  62. else
  63. 'Starting optimization...'
  64. end
  65. render json: {
  66. percentage: percentage,
  67. message: message,
  68. completed: percentage >= 100,
  69. completed_count: completed_count,
  70. total_jobs: total_jobs
  71. }
  72. end
  73. # POST /admin/media/regenerate_variants
  74. def regenerate_variants
  75. upload_id = params[:upload_id]
  76. if upload_id
  77. # Regenerate variants for specific upload
  78. upload = Upload.find(upload_id)
  79. medium = upload.media.first
  80. if medium
  81. OptimizeImageJob.perform_later(medium_id: medium.id)
  82. render json: {
  83. success: true,
  84. message: 'Variants regeneration queued'
  85. }
  86. else
  87. render json: {
  88. success: false,
  89. message: 'No medium found for this upload'
  90. }
  91. end
  92. else
  93. # Regenerate variants for all images
  94. optimized_uploads = Upload.joins(:file_attachment)
  95. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  96. .where.not(variants: [nil, {}])
  97. job_count = 0
  98. optimized_uploads.find_each do |upload|
  99. upload.media.each do |medium|
  100. OptimizeImageJob.perform_later(medium_id: medium.id)
  101. job_count += 1
  102. end
  103. end
  104. render json: {
  105. success: true,
  106. message: "Queued #{job_count} images for variant regeneration"
  107. }
  108. end
  109. end
  110. # DELETE /admin/media/clear_variants
  111. def clear_variants
  112. upload_id = params[:upload_id]
  113. if upload_id
  114. # Clear variants for specific upload
  115. upload = Upload.find(upload_id)
  116. clear_upload_variants(upload)
  117. render json: {
  118. success: true,
  119. message: 'Variants cleared for this image'
  120. }
  121. else
  122. # Clear all variants (dangerous operation)
  123. render json: {
  124. success: false,
  125. message: 'Bulk variant clearing not implemented for safety'
  126. }
  127. end
  128. end
  129. # GET /admin/media/optimization_report
  130. def report
  131. @stats = calculate_detailed_stats
  132. @recent_optimizations = get_recent_optimizations
  133. @space_saved = calculate_space_saved
  134. end
  135. private
  136. def calculate_optimization_stats
  137. total_images = Upload.joins(:file_attachment)
  138. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  139. .count
  140. optimized_images = Upload.joins(:file_attachment)
  141. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  142. .where.not(variants: [nil, {}])
  143. .count
  144. webp_variants = Upload.where("variants LIKE ?", '%webp%').count
  145. avif_variants = Upload.where("variants LIKE ?", '%avif%').count
  146. {
  147. total_images: total_images,
  148. optimized_images: optimized_images,
  149. unoptimized_images: total_images - optimized_images,
  150. webp_variants: webp_variants,
  151. avif_variants: avif_variants,
  152. optimization_percentage: total_images > 0 ? ((optimized_images.to_f / total_images) * 100).round(1) : 0
  153. }
  154. end
  155. def count_unoptimized_images
  156. Upload.joins(:file_attachment)
  157. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  158. .where(variants: [nil, {}])
  159. .count
  160. end
  161. def count_optimized_images
  162. Upload.joins(:file_attachment)
  163. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  164. .where.not(variants: [nil, {}])
  165. .count
  166. end
  167. def get_unoptimized_uploads
  168. Upload.joins(:file_attachment)
  169. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  170. .where(variants: [nil, {}])
  171. end
  172. def clear_upload_variants(upload)
  173. return unless upload.variants
  174. # Delete variant blobs
  175. upload.variants.each do |format, variant_data|
  176. blob_id = variant_data['blob_id']
  177. blob = ActiveStorage::Blob.find_by(id: blob_id)
  178. blob&.purge
  179. end
  180. # Clear variants from upload
  181. upload.update!(variants: {})
  182. end
  183. def calculate_detailed_stats
  184. stats = calculate_optimization_stats
  185. # Add more detailed statistics
  186. stats.merge({
  187. responsive_variants: count_responsive_variants,
  188. average_file_size: calculate_average_file_size,
  189. total_storage_used: calculate_total_storage_used
  190. })
  191. end
  192. def count_responsive_variants
  193. Upload.where("variants LIKE ?", '%_w%').count
  194. end
  195. def calculate_average_file_size
  196. Upload.joins(:file_attachment)
  197. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  198. .average('active_storage_blobs.byte_size')
  199. &.round(2) || 0
  200. end
  201. def calculate_total_storage_used
  202. Upload.joins(:file_attachment)
  203. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  204. .sum('active_storage_blobs.byte_size')
  205. &.round(2) || 0
  206. end
  207. def calculate_space_saved
  208. # Estimate space saved based on optimization
  209. total_images = calculate_optimization_stats[:total_images]
  210. optimized_images = calculate_optimization_stats[:optimized_images]
  211. # Assume 30% average savings per optimized image
  212. estimated_savings = (optimized_images * 0.3).round(2)
  213. {
  214. estimated_mb_saved: estimated_savings,
  215. estimated_percentage: total_images > 0 ? ((estimated_savings / total_images) * 100).round(1) : 0
  216. }
  217. end
  218. def get_recent_optimizations
  219. Upload.joins(:file_attachment)
  220. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
  221. .where.not(variants: [nil, {}])
  222. .order(updated_at: :desc)
  223. .limit(10)
  224. end
  225. end

app/controllers/admin/cache_controller.rb

0.0% lines covered

100.0% branches covered

214 relevant lines. 0 lines covered and 214 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::CacheController < Admin::BaseController
  2. def index
  3. # Load Redis settings from system configuration
  4. @cache_enabled = SiteSetting.get('redis_enabled', Rails.cache.is_a?(ActiveSupport::Cache::RedisCacheStore))
  5. @redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'] || 'redis://localhost:6379/0')
  6. @cache_url = SiteSetting.get('redis_cache_url', ENV['REDIS_CACHE_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/1')
  7. @session_url = SiteSetting.get('redis_session_url', ENV['REDIS_SESSION_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/2')
  8. @timeout = SiteSetting.get('redis_timeout', 5)
  9. @connect_timeout = SiteSetting.get('redis_connect_timeout', 5)
  10. @reconnect_attempts = SiteSetting.get('redis_reconnect_attempts', 3)
  11. @reconnect_delay = SiteSetting.get('redis_reconnect_delay', 0.5)
  12. @reconnect_delay_max = SiteSetting.get('redis_reconnect_delay_max', 2.0)
  13. @cache_expires_in = (SiteSetting.get('redis_cache_expires_in', 1.hour.to_i) / 1.hour.to_i)
  14. @session_expires_in = (SiteSetting.get('redis_session_expires_in', 24.hours.to_i) / 1.hour.to_i)
  15. @redis_configured = defined?(Redis)
  16. # Get Redis connection info if available
  17. begin
  18. redis_url = @redis_url
  19. if defined?(Redis) && redis_url.present?
  20. redis = Redis.new(url: redis_url)
  21. @redis_info = redis.info
  22. @redis_connected = true
  23. @redis_configured = true
  24. # Get additional stats
  25. @redis_stats = {
  26. db_size: redis.dbsize,
  27. memory_usage: @redis_info['used_memory_human'],
  28. connected_clients: @redis_info['connected_clients'],
  29. version: @redis_info['redis_version'],
  30. uptime: @redis_info['uptime_in_seconds']
  31. }
  32. # Calculate hit rate if available
  33. if @redis_info['keyspace_hits'] && @redis_info['keyspace_misses']
  34. total_requests = @redis_info['keyspace_hits'].to_f + @redis_info['keyspace_misses'].to_f
  35. @redis_stats[:hit_rate] = total_requests > 0 ? (@redis_info['keyspace_hits'].to_f / total_requests) : 0
  36. else
  37. @redis_stats[:hit_rate] = 0
  38. end
  39. redis.quit
  40. else
  41. @redis_connected = false
  42. @redis_configured = false
  43. @redis_info = {}
  44. @redis_stats = {}
  45. end
  46. rescue => e
  47. @redis_connected = false
  48. @redis_configured = false
  49. @redis_info = {}
  50. @redis_stats = {}
  51. @redis_error = e.message
  52. end
  53. end
  54. def update
  55. redis_params = params.permit(
  56. :enabled, :url, :cache_url, :session_url, :timeout, :connect_timeout,
  57. :reconnect_attempts, :reconnect_delay, :reconnect_delay_max,
  58. :cache_expires_in, :session_expires_in
  59. )
  60. begin
  61. # Convert enabled checkbox to boolean
  62. enabled = redis_params[:enabled] == '1'
  63. # Save Redis settings to SiteSetting
  64. SiteSetting.set('redis_enabled', enabled, 'general')
  65. SiteSetting.set('redis_url', redis_params[:url], 'general')
  66. SiteSetting.set('redis_cache_url', redis_params[:cache_url], 'general')
  67. SiteSetting.set('redis_session_url', redis_params[:session_url], 'general')
  68. SiteSetting.set('redis_timeout', redis_params[:timeout].to_i, 'general')
  69. SiteSetting.set('redis_connect_timeout', redis_params[:connect_timeout].to_i, 'general')
  70. SiteSetting.set('redis_reconnect_attempts', redis_params[:reconnect_attempts].to_i, 'general')
  71. SiteSetting.set('redis_reconnect_delay', redis_params[:reconnect_delay].to_f, 'general')
  72. SiteSetting.set('redis_reconnect_delay_max', redis_params[:reconnect_delay_max].to_f, 'general')
  73. SiteSetting.set('redis_cache_expires_in', redis_params[:cache_expires_in].to_i.hours.to_i, 'general')
  74. SiteSetting.set('redis_session_expires_in', redis_params[:session_expires_in].to_i.hours.to_i, 'general')
  75. # Test the connection with new settings
  76. if enabled && redis_params[:url].present?
  77. begin
  78. redis = Redis.new(url: redis_params[:url])
  79. redis.ping
  80. redis.quit
  81. message = "Redis settings updated and connection tested successfully! Note: Some changes may require application restart."
  82. rescue => e
  83. message = "Redis settings saved but connection test failed: #{e.message}"
  84. end
  85. else
  86. message = "Redis settings updated successfully!"
  87. end
  88. if request.xhr?
  89. render json: { success: true, message: message }
  90. else
  91. flash[:notice] = message
  92. redirect_to admin_cache_path
  93. end
  94. rescue => e
  95. error_message = "Failed to update Redis settings: #{e.message}"
  96. if request.xhr?
  97. render json: { success: false, message: error_message }
  98. else
  99. flash[:alert] = error_message
  100. redirect_to admin_cache_path
  101. end
  102. end
  103. end
  104. def test_connection
  105. begin
  106. redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'] || 'redis://localhost:6379/0')
  107. redis = Redis.new(url: redis_url)
  108. info = redis.info
  109. redis.quit
  110. message = "Redis connection test successful!"
  111. if request.xhr?
  112. render json: { success: true, message: message }
  113. else
  114. flash[:notice] = message
  115. redirect_to admin_cache_path
  116. end
  117. rescue => e
  118. error_message = "Redis connection test failed: #{e.message}"
  119. if request.xhr?
  120. render json: { success: false, message: error_message }
  121. else
  122. flash[:alert] = error_message
  123. redirect_to admin_cache_path
  124. end
  125. end
  126. end
  127. def flush_cache
  128. begin
  129. # Flush Rails cache
  130. Rails.cache.clear
  131. # Also flush Redis directly if available
  132. redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'])
  133. if defined?(Redis) && redis_url
  134. redis = Redis.new(url: redis_url)
  135. redis.flushdb
  136. redis.quit
  137. end
  138. message = "Cache flushed successfully!"
  139. if request.xhr?
  140. render json: { success: true, message: message }
  141. else
  142. flash[:notice] = message
  143. redirect_to admin_cache_path
  144. end
  145. rescue => e
  146. error_message = "Failed to flush cache: #{e.message}"
  147. if request.xhr?
  148. render json: { success: false, message: error_message }
  149. else
  150. flash[:alert] = error_message
  151. redirect_to admin_cache_path
  152. end
  153. end
  154. end
  155. def stats
  156. begin
  157. if defined?(Redis) && ENV['REDIS_URL']
  158. redis = Redis.new(url: ENV['REDIS_URL'])
  159. info = redis.info
  160. # Get database size
  161. db_size = redis.dbsize
  162. # Calculate hit rate if available
  163. hit_rate = 0
  164. if info['keyspace_hits'] && info['keyspace_misses']
  165. total_requests = info['keyspace_hits'].to_f + info['keyspace_misses'].to_f
  166. hit_rate = total_requests > 0 ? (info['keyspace_hits'].to_f / total_requests) : 0
  167. end
  168. redis.quit
  169. render json: {
  170. success: true,
  171. stats: {
  172. total_keys: db_size,
  173. memory_usage: info['used_memory_human'],
  174. hit_rate: hit_rate,
  175. connected_clients: info['connected_clients'],
  176. uptime: info['uptime_in_seconds'],
  177. version: info['redis_version']
  178. }
  179. }
  180. else
  181. render json: {
  182. success: false,
  183. message: "Redis not available"
  184. }
  185. end
  186. rescue => e
  187. render json: {
  188. success: false,
  189. message: "Failed to get Redis stats: #{e.message}"
  190. }
  191. end
  192. end
  193. def enable
  194. begin
  195. SiteSetting.set('redis_enabled', true, 'general')
  196. flash[:notice] = "Cache enabled successfully!"
  197. rescue => e
  198. flash[:alert] = "Failed to enable cache: #{e.message}"
  199. end
  200. redirect_to admin_cache_path
  201. end
  202. def disable
  203. begin
  204. SiteSetting.set('redis_enabled', false, 'general')
  205. flash[:notice] = "Cache disabled successfully!"
  206. rescue => e
  207. flash[:alert] = "Failed to disable cache: #{e.message}"
  208. end
  209. redirect_to admin_cache_path
  210. end
  211. def clear
  212. begin
  213. # Clear Rails cache
  214. Rails.cache.clear
  215. # Also clear Redis directly if available
  216. if defined?(Redis) && ENV['REDIS_URL']
  217. redis = Redis.new(url: ENV['REDIS_URL'])
  218. redis.flushdb
  219. redis.quit
  220. end
  221. flash[:notice] = "Cache cleared successfully!"
  222. rescue => e
  223. flash[:alert] = "Failed to clear cache: #{e.message}"
  224. end
  225. redirect_to admin_cache_path
  226. end
  227. end

app/controllers/admin/categories_controller.rb

0.0% lines covered

100.0% branches covered

97 relevant lines. 0 lines covered and 97 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::CategoriesController < Admin::BaseController
  2. before_action :set_taxonomy
  3. before_action :set_term, only: %i[ show edit update destroy ]
  4. # GET /admin/categories or /admin/categories.json
  5. def index
  6. @terms = @taxonomy.terms.includes(:term_relationships).order(:name)
  7. respond_to do |format|
  8. format.html
  9. format.json {
  10. render json: @terms.map { |term|
  11. {
  12. id: term.id,
  13. name: term.name,
  14. slug: term.slug,
  15. description: term.description,
  16. posts_count: term.term_relationships.where(object_type: 'Post').count,
  17. parent_id: term.parent_id,
  18. parent_name: term.parent&.name,
  19. created_at: term.created_at.strftime('%B %d, %Y')
  20. }
  21. }
  22. }
  23. end
  24. end
  25. # GET /admin/categories/1 or /admin/categories/1.json
  26. def show
  27. @posts = Post.joins(:term_relationships)
  28. .where(term_relationships: { term_id: @term.id })
  29. .order(created_at: :desc)
  30. .page(params[:page])
  31. end
  32. # GET /admin/categories/new
  33. def new
  34. @term = @taxonomy.terms.new
  35. @parent_categories = @taxonomy.terms.where(parent_id: nil).order(:name)
  36. end
  37. # GET /admin/categories/1/edit
  38. def edit
  39. @parent_categories = @taxonomy.terms.where(parent_id: nil).where.not(id: @term.id).order(:name)
  40. end
  41. # POST /admin/categories or /admin/categories.json
  42. def create
  43. @term = @taxonomy.terms.new(term_params)
  44. respond_to do |format|
  45. if @term.save
  46. format.html { redirect_to admin_category_path(@term), notice: "Category was successfully created." }
  47. format.json { render :show, status: :created, location: admin_category_path(@term) }
  48. else
  49. @parent_categories = @taxonomy.terms.where(parent_id: nil).order(:name)
  50. format.html { render :new, status: :unprocessable_entity }
  51. format.json { render json: @term.errors, status: :unprocessable_entity }
  52. end
  53. end
  54. end
  55. # PATCH/PUT /admin/categories/1 or /admin/categories/1.json
  56. def update
  57. respond_to do |format|
  58. if @term.update(term_params)
  59. format.html { redirect_to admin_category_path(@term), notice: "Category was successfully updated.", status: :see_other }
  60. format.json { render :show, status: :ok, location: admin_category_path(@term) }
  61. else
  62. @parent_categories = @taxonomy.terms.where(parent_id: nil).where.not(id: @term.id).order(:name)
  63. format.html { render :edit, status: :unprocessable_entity }
  64. format.json { render json: @term.errors, status: :unprocessable_entity }
  65. end
  66. end
  67. end
  68. # DELETE /admin/categories/1 or /admin/categories/1.json
  69. def destroy
  70. # Check if category has posts
  71. posts_count = @term.term_relationships.where(object_type: 'Post').count
  72. if posts_count > 0 && @term.slug == 'uncategorized'
  73. respond_to do |format|
  74. format.html { redirect_to admin_categories_path, alert: "Cannot delete the Uncategorized category.", status: :see_other }
  75. format.json { render json: { error: "Cannot delete default category" }, status: :unprocessable_entity }
  76. end
  77. return
  78. end
  79. # Move posts to Uncategorized if deleting non-default category
  80. if posts_count > 0
  81. uncategorized = @taxonomy.terms.find_by(slug: 'uncategorized')
  82. @term.term_relationships.where(object_type: 'Post').each do |rel|
  83. rel.update(term_id: uncategorized.id) if uncategorized
  84. end
  85. end
  86. @term.destroy!
  87. respond_to do |format|
  88. format.html { redirect_to admin_categories_path, notice: "Category was successfully deleted.", status: :see_other }
  89. format.json { head :no_content }
  90. end
  91. end
  92. private
  93. # Set the category taxonomy
  94. def set_taxonomy
  95. @taxonomy = Taxonomy.find_by!(slug: 'category')
  96. rescue ActiveRecord::RecordNotFound
  97. redirect_to admin_taxonomies_path, alert: "Category taxonomy not found. Please run seeds."
  98. end
  99. # Use callbacks to share common setup or constraints between actions.
  100. def set_term
  101. @term = @taxonomy.terms.find(params[:id])
  102. rescue ActiveRecord::RecordNotFound
  103. redirect_to admin_categories_path, alert: "Category not found."
  104. end
  105. # Only allow a list of trusted parameters through.
  106. def term_params
  107. params.require(:term).permit(:name, :slug, :description, :parent_id, :meta)
  108. end
  109. end

app/controllers/admin/channels_controller.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ChannelsController < Admin::BaseController
  2. before_action :set_channel, only: [:show, :edit, :update, :destroy]
  3. def index
  4. @channels = Channel.all.order(:name)
  5. end
  6. def show
  7. @overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
  8. @overrides_by_type = @overrides.group_by(&:resource_type)
  9. end
  10. def new
  11. @channel = Channel.new
  12. end
  13. def create
  14. @channel = Channel.new(channel_params)
  15. if @channel.save
  16. redirect_to admin_channel_path(@channel), notice: 'Channel was successfully created.'
  17. else
  18. render :new
  19. end
  20. end
  21. def edit
  22. end
  23. def update
  24. if @channel.update(channel_params)
  25. redirect_to admin_channel_path(@channel), notice: 'Channel was successfully updated.'
  26. else
  27. render :edit
  28. end
  29. end
  30. def destroy
  31. @channel.destroy
  32. redirect_to admin_channels_path, notice: 'Channel was successfully deleted.'
  33. end
  34. private
  35. def set_channel
  36. @channel = Channel.find(params[:id])
  37. end
  38. def channel_params
  39. params.require(:channel).permit(:name, :slug, :domain, :locale, metadata: {}, settings: {})
  40. end
  41. end

app/controllers/admin/comments_controller.rb

0.0% lines covered

100.0% branches covered

232 relevant lines. 0 lines covered and 232 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::CommentsController < Admin::BaseController
  2. before_action :set_comment, only: %i[ show edit update destroy ]
  3. # GET /admin/comments or /admin/comments.json
  4. def index
  5. @comments = Comment.kept.includes(:commentable, :user).order(created_at: :desc)
  6. # Show trashed if explicitly requested
  7. if params[:show_trash] == 'true'
  8. @comments = Comment.trashed.includes(:commentable, :user).order(deleted_at: :desc)
  9. end
  10. respond_to do |format|
  11. format.html do
  12. @comments_data = comments_json
  13. @stats = {
  14. total: Comment.kept.count,
  15. approved: Comment.kept.where(status: 'approved').count,
  16. pending: Comment.kept.where(status: 'pending').count,
  17. spam: Comment.kept.where(status: 'spam').count
  18. }
  19. @bulk_actions = [
  20. { value: 'approve', label: 'Approve' },
  21. { value: 'unapprove', label: 'Unapprove' },
  22. { value: 'spam', label: 'Mark as Spam' },
  23. { value: 'trash', label: 'Move to Trash' },
  24. { value: 'untrash', label: 'Restore' }
  25. ]
  26. @status_options = [
  27. { value: 'approved', label: 'Approved' },
  28. { value: 'pending', label: 'Pending' },
  29. { value: 'spam', label: 'Spam' },
  30. { value: 'trash', label: 'Trash' }
  31. ]
  32. @columns = [
  33. {
  34. title: "",
  35. formatter: "rowSelection",
  36. titleFormatter: "rowSelection",
  37. width: 40,
  38. headerSort: false
  39. },
  40. {
  41. title: "Author",
  42. field: "author_name",
  43. width: 150,
  44. formatter: "html"
  45. },
  46. {
  47. title: "Comment",
  48. field: "content",
  49. width: 250,
  50. formatter: "html"
  51. },
  52. {
  53. title: "Type",
  54. field: "type",
  55. width: 80,
  56. formatter: "html"
  57. },
  58. {
  59. title: "In Response To",
  60. field: "commentable_title",
  61. width: 150,
  62. formatter: "html"
  63. },
  64. {
  65. title: "Status",
  66. field: "status",
  67. width: 100,
  68. formatter: "html"
  69. },
  70. {
  71. title: "IP",
  72. field: "author_ip",
  73. width: 120
  74. },
  75. {
  76. title: "Browser",
  77. field: "browser_info",
  78. width: 100
  79. },
  80. {
  81. title: "Date",
  82. field: "created_at",
  83. width: 150,
  84. formatter: "datetime",
  85. formatterParams: {
  86. inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
  87. outputFormat: "DD/MM/YYYY HH:mm"
  88. }
  89. },
  90. {
  91. title: "Actions",
  92. field: "actions",
  93. width: 120,
  94. headerSort: false,
  95. formatter: "html"
  96. }
  97. ]
  98. end
  99. format.json { render json: comments_json }
  100. end
  101. end
  102. # GET /admin/comments/1 or /admin/comments/1.json
  103. def show
  104. end
  105. # GET /admin/comments/1/edit
  106. def edit
  107. end
  108. # POST /admin/comments or /admin/comments.json
  109. def create
  110. @comment = Comment.new(comment_params)
  111. respond_to do |format|
  112. if @comment.save
  113. format.html { redirect_to admin_comments_path, notice: "Comment was successfully created." }
  114. format.json { render :show, status: :created, location: @comment }
  115. else
  116. format.html { redirect_to admin_comments_path, alert: "Failed to create comment." }
  117. format.json { render json: @comment.errors, status: :unprocessable_entity }
  118. end
  119. end
  120. end
  121. # PATCH/PUT /admin/comments/1 or /admin/comments/1.json
  122. def update
  123. respond_to do |format|
  124. if @comment.update(comment_params)
  125. format.html { redirect_to [:admin, @comment], notice: "Comment was successfully updated.", status: :see_other }
  126. format.json { render :show, status: :ok, location: @comment }
  127. else
  128. format.html { render :edit, status: :unprocessable_entity }
  129. format.json { render json: @comment.errors, status: :unprocessable_entity }
  130. end
  131. end
  132. end
  133. # DELETE /admin/comments/1 or /admin/comments/1.json
  134. def destroy
  135. if @comment.trashed?
  136. @comment.destroy_permanently! # Permanent delete
  137. notice = "Comment was permanently deleted."
  138. else
  139. @comment.trash!(current_user) # Soft delete
  140. notice = "Comment was moved to trash."
  141. end
  142. respond_to do |format|
  143. format.html { redirect_to admin_comments_path, notice: notice, status: :see_other }
  144. format.json { head :no_content }
  145. end
  146. end
  147. # POST /admin/comments/bulk_action
  148. def bulk_action
  149. action_type = params[:action_type]
  150. comment_ids = params[:ids] || []
  151. comments = Comment.where(id: comment_ids)
  152. case action_type
  153. when 'approve'
  154. comments.find_each(&:approve!)
  155. message = "#{comments.count} comments approved"
  156. when 'unapprove'
  157. comments.find_each(&:unapprove!)
  158. message = "#{comments.count} comments unapproved"
  159. when 'spam'
  160. comments.find_each { |comment| comment.update!(status: :spam) }
  161. message = "#{comments.count} comments marked as spam"
  162. when 'trash'
  163. comments.find_each { |comment| comment.trash!(current_user) }
  164. message = "#{comments.count} comments moved to trash"
  165. when 'untrash'
  166. comments.find_each(&:untrash!)
  167. message = "#{comments.count} comments restored from trash"
  168. else
  169. message = "Invalid action"
  170. end
  171. respond_to do |format|
  172. format.json { render json: { success: true, message: message } }
  173. end
  174. end
  175. private
  176. # Use callbacks to share common setup or constraints between actions.
  177. def set_comment
  178. @comment = Comment.find(params[:id])
  179. end
  180. # Only allow a list of trusted parameters through.
  181. def comment_params
  182. params.require(:comment).permit(
  183. :content, :author_name, :author_email, :author_url, :author_ip, :author_agent,
  184. :status, :comment_type, :comment_approved, :comment_parent_id, :user_id,
  185. :commentable_type, :commentable_id, :parent_id
  186. )
  187. end
  188. def comments_json
  189. @comments.map do |comment|
  190. {
  191. id: comment.id,
  192. author_name: format_author_name(comment),
  193. author_email: comment.author_email,
  194. content: format_content(comment.content),
  195. type: format_comment_type(comment.comment_type),
  196. status: format_status_badge(comment.status),
  197. status_raw: comment.status,
  198. commentable_type: comment.commentable_type,
  199. commentable_title: comment.commentable&.title || 'Unknown',
  200. author_ip: comment.author_ip,
  201. browser_info: comment.browser_info,
  202. created_at: comment.created_at.iso8601,
  203. edit_url: edit_admin_comment_path(comment),
  204. show_url: admin_comment_path(comment),
  205. delete_url: nil
  206. }
  207. end
  208. end
  209. def format_author_name(comment)
  210. if comment.user.present?
  211. "<div class='flex flex-col'>
  212. <span class='font-medium text-gray-900'>#{comment.user.name}</span>
  213. <span class='text-xs text-gray-500'>(#{comment.author_name})</span>
  214. </div>"
  215. else
  216. "<span class='font-medium text-gray-900'>#{comment.author_name}</span>"
  217. end
  218. end
  219. def format_content(content)
  220. truncated = content.length > 100 ? content[0..100] + '...' : content
  221. "<div class='text-sm text-gray-700'>#{truncated}</div>"
  222. end
  223. def format_comment_type(type)
  224. type_map = {
  225. 'comment' => { class: 'bg-blue-100 text-blue-800', label: 'Comment' },
  226. 'pingback' => { class: 'bg-purple-100 text-purple-800', label: 'Pingback' },
  227. 'trackback' => { class: 'bg-orange-100 text-orange-800', label: 'Trackback' }
  228. }
  229. type_info = type_map[type] || { class: 'bg-gray-100 text-gray-800', label: type&.capitalize || 'Unknown' }
  230. "<span class='px-2 py-1 text-xs font-medium rounded-full #{type_info[:class]}'>#{type_info[:label]}</span>"
  231. end
  232. def format_status_badge(status)
  233. status_map = {
  234. 'approved' => { class: 'bg-green-100 text-green-800', label: 'Approved' },
  235. 'pending' => { class: 'bg-yellow-100 text-yellow-800', label: 'Pending' },
  236. 'spam' => { class: 'bg-red-100 text-red-800', label: 'Spam' },
  237. 'trash' => { class: 'bg-gray-100 text-gray-800', label: 'Trash' }
  238. }
  239. status_info = status_map[status] || { class: 'bg-gray-100 text-gray-800', label: status&.capitalize || 'Unknown' }
  240. "<span class='px-2 py-1 text-xs font-medium rounded-full #{status_info[:class]}'>#{status_info[:label]}</span>"
  241. end
  242. end

app/controllers/admin/consent/consent_configurations_controller.rb

0.0% lines covered

100.0% branches covered

101 relevant lines. 0 lines covered and 101 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Consent::ConsentConfigurationsController < Admin::BaseController
  2. before_action :set_consent_configuration, only: [:show, :edit, :update, :destroy]
  3. # GET /admin/consent/configurations
  4. def index
  5. @consent_configs = ConsentConfiguration.includes(:tenant).ordered
  6. @consent_configs = @consent_configs.page(params[:page]).per(20)
  7. end
  8. # GET /admin/consent/configurations/1
  9. def show
  10. end
  11. # GET /admin/consent/configurations/new
  12. def new
  13. @consent_configuration = ConsentConfiguration.new
  14. end
  15. # GET /admin/consent/configurations/1/preview
  16. def preview
  17. @consent_configuration = ConsentConfiguration.find(params[:id])
  18. # Create a temporary configuration with updated settings for preview
  19. if params[:consent_configuration]
  20. # Start with the original configuration and modify it
  21. @preview_config = @consent_configuration.dup
  22. # Update banner settings with form data
  23. banner_settings = @consent_configuration.banner_settings_with_defaults.dup
  24. # Text settings
  25. banner_settings['text']['title'] = params[:consent_configuration][:banner_title] if params[:consent_configuration][:banner_title].present?
  26. banner_settings['text']['description'] = params[:consent_configuration][:banner_description] if params[:consent_configuration][:banner_description].present?
  27. banner_settings['text']['accept_all'] = params[:consent_configuration][:accept_all_text] if params[:consent_configuration][:accept_all_text].present?
  28. banner_settings['text']['reject_all'] = params[:consent_configuration][:reject_all_text] if params[:consent_configuration][:reject_all_text].present?
  29. # Color settings
  30. banner_settings['colors']['background'] = params[:consent_configuration][:banner_background_color] if params[:consent_configuration][:banner_background_color].present?
  31. banner_settings['colors']['text'] = params[:consent_configuration][:banner_text_color] if params[:consent_configuration][:banner_text_color].present?
  32. banner_settings['colors']['button_accept'] = params[:consent_configuration][:accept_button_bg_color] if params[:consent_configuration][:accept_button_bg_color].present?
  33. banner_settings['colors']['button_reject'] = params[:consent_configuration][:reject_button_bg_color] if params[:consent_configuration][:reject_button_bg_color].present?
  34. banner_settings['colors']['button_neutral'] = params[:consent_configuration][:neutral_button_bg_color] if params[:consent_configuration][:neutral_button_bg_color].present?
  35. # Apply the updated banner settings
  36. @preview_config.banner_settings = banner_settings
  37. # Also update other form fields if they exist
  38. @preview_config.name = params[:consent_configuration][:name] if params[:consent_configuration][:name].present?
  39. @preview_config.banner_type = params[:consent_configuration][:banner_type] if params[:consent_configuration][:banner_type].present?
  40. @preview_config.consent_mode = params[:consent_configuration][:consent_mode] if params[:consent_configuration][:consent_mode].present?
  41. else
  42. @preview_config = @consent_configuration
  43. end
  44. render layout: 'consent_preview'
  45. end
  46. # POST /admin/consent/configurations
  47. def create
  48. @consent_configuration = ConsentConfiguration.new(consent_configuration_params)
  49. if @consent_configuration.save
  50. redirect_to admin_consent_consent_configuration_path(@consent_configuration), notice: 'Consent configuration was successfully created.'
  51. else
  52. render :new, status: :unprocessable_entity
  53. end
  54. end
  55. # PATCH/PUT /admin/consent/configurations/1
  56. def update
  57. # Extract banner settings from form parameters
  58. banner_settings = @consent_configuration.banner_settings_with_defaults.dup
  59. # Update text settings
  60. if params[:consent_configuration][:banner_title].present?
  61. banner_settings['text']['title'] = params[:consent_configuration][:banner_title]
  62. end
  63. if params[:consent_configuration][:banner_description].present?
  64. banner_settings['text']['description'] = params[:consent_configuration][:banner_description]
  65. end
  66. if params[:consent_configuration][:accept_all_text].present?
  67. banner_settings['text']['accept_all'] = params[:consent_configuration][:accept_all_text]
  68. end
  69. if params[:consent_configuration][:reject_all_text].present?
  70. banner_settings['text']['reject_all'] = params[:consent_configuration][:reject_all_text]
  71. end
  72. # Update color settings
  73. if params[:consent_configuration][:banner_background_color].present?
  74. banner_settings['colors']['background'] = params[:consent_configuration][:banner_background_color]
  75. end
  76. if params[:consent_configuration][:banner_text_color].present?
  77. banner_settings['colors']['text'] = params[:consent_configuration][:banner_text_color]
  78. end
  79. if params[:consent_configuration][:accept_button_bg_color].present?
  80. banner_settings['colors']['button_accept'] = params[:consent_configuration][:accept_button_bg_color]
  81. end
  82. if params[:consent_configuration][:reject_button_bg_color].present?
  83. banner_settings['colors']['button_reject'] = params[:consent_configuration][:reject_button_bg_color]
  84. end
  85. if params[:consent_configuration][:neutral_button_bg_color].present?
  86. banner_settings['colors']['button_neutral'] = params[:consent_configuration][:neutral_button_bg_color]
  87. end
  88. # Update the model with only the allowed attributes
  89. update_params = consent_configuration_params.except(
  90. :banner_title, :banner_description, :accept_all_text, :reject_all_text,
  91. :banner_background_color, :banner_text_color, :accept_button_bg_color,
  92. :reject_button_bg_color, :neutral_button_bg_color, :modal_background_color, :modal_text_color
  93. )
  94. update_params[:banner_settings] = banner_settings
  95. if @consent_configuration.update(update_params)
  96. redirect_to edit_admin_consent_consent_configuration_path(@consent_configuration), notice: 'Consent configuration was successfully updated.'
  97. else
  98. render :edit, status: :unprocessable_entity
  99. end
  100. end
  101. # DELETE /admin/consent/configurations/1
  102. def destroy
  103. @consent_configuration.destroy
  104. redirect_to admin_consent_consent_configurations_path, notice: 'Consent configuration was successfully deleted.'
  105. end
  106. private
  107. def set_consent_configuration
  108. @consent_configuration = ConsentConfiguration.find(params[:id])
  109. end
  110. def consent_configuration_params
  111. params.require(:consent_configuration).permit(
  112. :name, :banner_type, :consent_mode, :active, :tenant_id,
  113. :consent_categories, :pixel_consent_mapping, :banner_settings, :geolocation_settings,
  114. # These are used for form processing but not stored as individual attributes
  115. :banner_title, :banner_description, :accept_all_text, :reject_all_text,
  116. :banner_background_color, :banner_text_color, :accept_button_bg_color,
  117. :reject_button_bg_color, :neutral_button_bg_color, :modal_background_color, :modal_text_color
  118. )
  119. end
  120. end

app/controllers/admin/consent/consent_controller.rb

0.0% lines covered

100.0% branches covered

328 relevant lines. 0 lines covered and 328 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Consent::ConsentController < Admin::BaseController
  2. before_action :set_consent_configuration, only: [:show, :edit, :update, :destroy]
  3. before_action :set_pixel, only: [:pixel_consent_settings]
  4. # GET /admin/consent
  5. def index
  6. @consent_configs = ConsentConfiguration.includes(:tenant).ordered
  7. @stats = {
  8. total_configs: ConsentConfiguration.count,
  9. active_configs: ConsentConfiguration.active.count,
  10. total_consents: UserConsent.count,
  11. granted_consents: UserConsent.granted.count,
  12. withdrawn_consents: UserConsent.withdrawn.count
  13. }
  14. end
  15. # GET /admin/consent/new
  16. def new
  17. @consent_config = ConsentConfiguration.new
  18. end
  19. # GET /admin/consent/:id
  20. def show
  21. @recent_consents = UserConsent.recent.limit(50)
  22. @consent_stats = {
  23. by_type: UserConsent.group(:consent_type).count,
  24. by_status: UserConsent.group(:granted).count,
  25. recent_granted: UserConsent.granted.where('granted_at > ?', 7.days.ago).count,
  26. recent_withdrawn: UserConsent.withdrawn.where('withdrawn_at > ?', 7.days.ago).count
  27. }
  28. end
  29. # GET /admin/consent/:id/edit
  30. def edit
  31. end
  32. # POST /admin/consent
  33. def create
  34. @consent_config = ConsentConfiguration.new(consent_configuration_params)
  35. if @consent_config.save
  36. redirect_to admin_consent_path(@consent_config), notice: 'Consent configuration created successfully.'
  37. else
  38. render :new, status: :unprocessable_entity
  39. end
  40. end
  41. # PATCH/PUT /admin/consent/:id
  42. def update
  43. if @consent_config.update(consent_configuration_params)
  44. redirect_to admin_consent_path(@consent_config), notice: 'Consent configuration updated successfully.'
  45. else
  46. render :edit, status: :unprocessable_entity
  47. end
  48. end
  49. # DELETE /admin/consent/:id
  50. def destroy
  51. @consent_config.destroy
  52. redirect_to admin_consent_index_path, notice: 'Consent configuration deleted successfully.'
  53. end
  54. # GET /admin/consent/pixels
  55. def pixels
  56. @pixels = Pixel.active.includes(:tenant).ordered
  57. @consent_config = ConsentConfiguration.active.first
  58. # Group pixels by consent category
  59. @pixels_by_category = {}
  60. if @consent_config
  61. @consent_config.consent_categories_with_defaults.each do |category, settings|
  62. @pixels_by_category[category] = @pixels.select do |pixel|
  63. @consent_config.get_consent_categories_for_pixel(pixel.pixel_type).include?(category)
  64. end
  65. end
  66. end
  67. end
  68. # GET /admin/consent/pixels/:id/consent_settings
  69. def pixel_consent_settings
  70. @consent_config = ConsentConfiguration.active.first
  71. @consent_categories = @consent_config&.consent_categories_with_defaults || {}
  72. @current_mapping = @consent_config&.pixel_consent_mapping_with_defaults || {}
  73. end
  74. # PATCH /admin/consent/pixels/:id/update_consent_mapping
  75. def update_pixel_consent_mapping
  76. pixel_id = params[:id]
  77. consent_categories = params[:consent_categories] || []
  78. begin
  79. consent_config = ConsentConfiguration.active.first
  80. if consent_config
  81. # Update pixel consent mapping
  82. current_mapping = consent_config.pixel_consent_mapping_with_defaults
  83. # Remove pixel from all categories first
  84. current_mapping.each do |category, pixels|
  85. current_mapping[category] = pixels - [Pixel.find(pixel_id).pixel_type]
  86. end
  87. # Add pixel to selected categories
  88. consent_categories.each do |category|
  89. current_mapping[category] ||= []
  90. current_mapping[category] << Pixel.find(pixel_id).pixel_type
  91. current_mapping[category].uniq!
  92. end
  93. consent_config.update!(pixel_consent_mapping: current_mapping)
  94. render json: {
  95. success: true,
  96. message: 'Pixel consent mapping updated successfully'
  97. }
  98. else
  99. render json: {
  100. success: false,
  101. error: 'No active consent configuration found'
  102. }, status: :not_found
  103. end
  104. rescue => e
  105. Rails.logger.error "Pixel consent mapping update error: #{e.message}"
  106. render json: {
  107. success: false,
  108. error: 'Failed to update pixel consent mapping'
  109. }, status: :unprocessable_entity
  110. end
  111. end
  112. # GET /admin/consent/users
  113. def users
  114. @users = User.includes(:user_consents).page(params[:page]).per(50)
  115. # Filter by consent status
  116. if params[:consent_status].present?
  117. case params[:consent_status]
  118. when 'has_consent'
  119. @users = @users.joins(:user_consents).where(user_consents: { granted: true })
  120. when 'no_consent'
  121. @users = @users.left_joins(:user_consents).where(user_consents: { id: nil })
  122. when 'withdrawn_consent'
  123. @users = @users.joins(:user_consents).where.not(user_consents: { withdrawn_at: nil })
  124. end
  125. end
  126. # Filter by consent type
  127. if params[:consent_type].present?
  128. @users = @users.joins(:user_consents).where(user_consents: { consent_type: params[:consent_type] })
  129. end
  130. end
  131. # GET /admin/consent/users/:id
  132. def user_consents
  133. @user = User.find(params[:id])
  134. @user_consents = @user.user_consents.recent
  135. @consent_config = ConsentConfiguration.active.first
  136. end
  137. # POST /admin/consent/users/:id/export_data
  138. def export_user_data
  139. @user = User.find(params[:id])
  140. begin
  141. # Create data export request
  142. export_request = PersonalDataExportRequest.create!(
  143. user: @user,
  144. status: 'pending',
  145. requested_at: Time.current,
  146. expires_at: 30.days.from_now
  147. )
  148. # Queue background job
  149. PersonalDataExportWorker.perform_async(export_request.id)
  150. redirect_to admin_consent_user_consents_path(@user),
  151. notice: 'Data export request created successfully. You will be notified when ready.'
  152. rescue => e
  153. Rails.logger.error "Data export error: #{e.message}"
  154. redirect_to admin_consent_user_consents_path(@user),
  155. alert: 'Failed to create data export request.'
  156. end
  157. end
  158. # DELETE /admin/consent/users/:id/consent/:consent_type
  159. def withdraw_user_consent
  160. @user = User.find(params[:id])
  161. consent_type = params[:consent_type]
  162. begin
  163. user_consent = @user.user_consents.find_by(consent_type: consent_type)
  164. if user_consent
  165. user_consent.withdraw!
  166. redirect_to admin_consent_user_consents_path(@user),
  167. notice: "#{consent_type.humanize} consent withdrawn successfully."
  168. else
  169. redirect_to admin_consent_user_consents_path(@user),
  170. alert: 'Consent not found.'
  171. end
  172. rescue => e
  173. Rails.logger.error "Consent withdrawal error: #{e.message}"
  174. redirect_to admin_consent_user_consents_path(@user),
  175. alert: 'Failed to withdraw consent.'
  176. end
  177. end
  178. # GET /admin/consent/analytics
  179. def analytics
  180. @time_range = params[:time_range] || '30_days'
  181. # Calculate time range
  182. case @time_range
  183. when '7_days'
  184. start_date = 7.days.ago
  185. when '30_days'
  186. start_date = 30.days.ago
  187. when '90_days'
  188. start_date = 90.days.ago
  189. when '1_year'
  190. start_date = 1.year.ago
  191. else
  192. start_date = 30.days.ago
  193. end
  194. @analytics = {
  195. consent_granted: UserConsent.granted.where('granted_at > ?', start_date).count,
  196. consent_withdrawn: UserConsent.withdrawn.where('withdrawn_at > ?', start_date).count,
  197. consent_by_type: UserConsent.where('granted_at > ? OR withdrawn_at > ?', start_date, start_date)
  198. .group(:consent_type)
  199. .group(:granted)
  200. .count,
  201. daily_consents: UserConsent.where('granted_at > ?', start_date)
  202. .group("DATE(granted_at)")
  203. .count,
  204. consent_rate: calculate_consent_rate(start_date)
  205. }
  206. end
  207. # GET /admin/consent/compliance
  208. def compliance
  209. @consent_config = ConsentConfiguration.active.first
  210. @compliance_report = generate_compliance_report
  211. end
  212. # GET /admin/consent/settings
  213. def settings
  214. @consent_config = ConsentConfiguration.active.first || ConsentConfiguration.new
  215. end
  216. # PATCH /admin/consent/settings
  217. def update_settings
  218. @consent_config = ConsentConfiguration.active.first || ConsentConfiguration.new
  219. if @consent_config.update(consent_configuration_params)
  220. redirect_to admin_consent_settings_path, notice: 'Consent settings updated successfully.'
  221. else
  222. render :settings, status: :unprocessable_entity
  223. end
  224. end
  225. # POST /admin/consent/test_banner
  226. def test_banner
  227. @consent_config = ConsentConfiguration.active.first
  228. if @consent_config
  229. render json: {
  230. banner_html: @consent_config.generate_banner_html,
  231. banner_css: @consent_config.generate_banner_css
  232. }
  233. else
  234. render json: {
  235. error: 'No active consent configuration found'
  236. }, status: :not_found
  237. end
  238. end
  239. private
  240. def set_consent_configuration
  241. @consent_config = ConsentConfiguration.find(params[:id])
  242. end
  243. def set_pixel
  244. @pixel = Pixel.find(params[:id])
  245. end
  246. def consent_configuration_params
  247. params.require(:consent_configuration).permit(
  248. :name,
  249. :banner_type,
  250. :consent_mode,
  251. :active,
  252. consent_categories: {},
  253. pixel_consent_mapping: {},
  254. banner_settings: {},
  255. geolocation_settings: {}
  256. )
  257. end
  258. def calculate_consent_rate(start_date)
  259. total_users = User.where('created_at > ?', start_date).count
  260. users_with_consent = User.joins(:user_consents)
  261. .where('user_consents.granted_at > ?', start_date)
  262. .distinct.count
  263. return 0 if total_users == 0
  264. (users_with_consent.to_f / total_users * 100).round(2)
  265. end
  266. def generate_compliance_report
  267. {
  268. gdpr_compliance: {
  269. data_subject_rights: check_data_subject_rights,
  270. consent_management: check_consent_management,
  271. data_processing_records: check_data_processing_records,
  272. privacy_by_design: check_privacy_by_design
  273. },
  274. ccpa_compliance: {
  275. consumer_rights: check_consumer_rights,
  276. opt_out_mechanism: check_opt_out_mechanism,
  277. data_disclosure: check_data_disclosure
  278. },
  279. overall_score: calculate_overall_compliance_score
  280. }
  281. end
  282. def check_data_subject_rights
  283. # Check if data subject rights are properly implemented
  284. {
  285. access_right: UserConsent.exists?,
  286. rectification_right: true, # Implemented in user management
  287. erasure_right: PersonalDataErasureRequest.exists?,
  288. portability_right: PersonalDataExportRequest.exists?,
  289. objection_right: UserConsent.withdrawn.exists?,
  290. score: 85
  291. }
  292. end
  293. def check_consent_management
  294. # Check consent management implementation
  295. {
  296. explicit_consent: UserConsent.granted.exists?,
  297. consent_withdrawal: UserConsent.withdrawn.exists?,
  298. consent_records: UserConsent.count > 0,
  299. consent_audit_trail: true, # Implemented with timestamps
  300. score: 90
  301. }
  302. end
  303. def check_data_processing_records
  304. # Check data processing records
  305. {
  306. processing_activities_documented: ConsentConfiguration.exists?,
  307. legal_basis_identified: true,
  308. data_categories_documented: true,
  309. retention_periods_set: true,
  310. score: 80
  311. }
  312. end
  313. def check_privacy_by_design
  314. # Check privacy by design implementation
  315. {
  316. consent_banner_implemented: ConsentConfiguration.active.exists?,
  317. data_minimization: true,
  318. purpose_limitation: true,
  319. storage_limitation: true,
  320. score: 75
  321. }
  322. end
  323. def check_consumer_rights
  324. # Check CCPA consumer rights
  325. {
  326. right_to_know: PersonalDataExportRequest.exists?,
  327. right_to_delete: PersonalDataErasureRequest.exists?,
  328. right_to_opt_out: UserConsent.withdrawn.exists?,
  329. non_discrimination: true,
  330. score: 85
  331. }
  332. end
  333. def check_opt_out_mechanism
  334. # Check opt-out mechanism
  335. {
  336. opt_out_link_available: true,
  337. opt_out_process_clear: true,
  338. opt_out_confirmation: true,
  339. score: 90
  340. }
  341. end
  342. def check_data_disclosure
  343. # Check data disclosure practices
  344. {
  345. privacy_policy_available: true,
  346. data_categories_disclosed: true,
  347. third_party_sharing_disclosed: true,
  348. score: 80
  349. }
  350. end
  351. def calculate_overall_compliance_score
  352. scores = [
  353. check_data_subject_rights[:score],
  354. check_consent_management[:score],
  355. check_data_processing_records[:score],
  356. check_privacy_by_design[:score],
  357. check_consumer_rights[:score],
  358. check_opt_out_mechanism[:score],
  359. check_data_disclosure[:score]
  360. ]
  361. (scores.sum.to_f / scores.length).round(2)
  362. end
  363. end

app/controllers/admin/content_analytics_controller.rb

0.0% lines covered

100.0% branches covered

126 relevant lines. 0 lines covered and 126 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ContentAnalyticsController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/analytics/posts/:id
  4. def post
  5. @post = Post.find(params[:id])
  6. @period = params[:period] || 'month'
  7. @analytics = ContentAnalyticsService.post_analytics(@post.id, period: @period.to_sym)
  8. # Chart data for views over time
  9. @views_chart_data = @analytics[:views_by_day].map do |date, count|
  10. { date: date, views: count }
  11. end
  12. # Chart data for views by hour
  13. @hourly_chart_data = @analytics[:views_by_hour].map do |hour, count|
  14. { hour: hour, views: count }
  15. end
  16. # Chart data for reader demographics
  17. @country_chart_data = @analytics[:readers_by_country].first(10).map do |country, count|
  18. { country: country, readers: count }
  19. end
  20. @device_chart_data = @analytics[:readers_by_device].map do |device, count|
  21. { device: device, readers: count }
  22. end
  23. end
  24. # GET /admin/analytics/pages/:id
  25. def page
  26. @page = Page.find(params[:id])
  27. @period = params[:period] || 'month'
  28. @analytics = ContentAnalyticsService.page_analytics(@page.id, period: @period.to_sym)
  29. # Chart data for views over time
  30. @views_chart_data = @analytics[:views_by_day].map do |date, count|
  31. { date: date, views: count }
  32. end
  33. # Chart data for views by hour
  34. @hourly_chart_data = @analytics[:views_by_hour].map do |hour, count|
  35. { hour: hour, views: count }
  36. end
  37. # Chart data for visitor demographics
  38. @country_chart_data = @analytics[:visitors_by_country].first(10).map do |country, count|
  39. { country: country, visitors: count }
  40. end
  41. @device_chart_data = @analytics[:visitors_by_device].map do |device, count|
  42. { device: device, visitors: count }
  43. end
  44. end
  45. # GET /admin/analytics/content/performance
  46. def performance
  47. @period = params[:period] || 'month'
  48. @limit = params[:limit]&.to_i || 10
  49. @performance_data = ContentAnalyticsService.top_performing_content(
  50. period: @period.to_sym,
  51. limit: @limit
  52. )
  53. end
  54. # GET /admin/analytics/content/engagement
  55. def engagement
  56. @period = params[:period] || 'month'
  57. @engagement_data = ContentAnalyticsService.reader_engagement_insights(period: @period.to_sym)
  58. # Chart data for engagement levels
  59. @engagement_chart_data = [
  60. { level: 'Low', count: @engagement_data[:low_engagement] },
  61. { level: 'Medium', count: @engagement_data[:medium_engagement] },
  62. { level: 'High', count: @engagement_data[:high_engagement] }
  63. ]
  64. # Chart data for reader segments
  65. @reader_segments_chart_data = [
  66. { segment: 'Quick Readers', count: @engagement_data[:quick_readers] },
  67. { segment: 'Engaged Readers', count: @engagement_data[:engaged_readers] },
  68. { segment: 'Deep Readers', count: @engagement_data[:deep_readers] }
  69. ]
  70. # Chart data for scroll behavior
  71. @scroll_chart_data = [
  72. { milestone: '25%', count: @engagement_data[:readers_who_scrolled_25] },
  73. { milestone: '50%', count: @engagement_data[:readers_who_scrolled_50] },
  74. { milestone: '75%', count: @engagement_data[:readers_who_scrolled_75] },
  75. { milestone: '100%', count: @engagement_data[:readers_who_scrolled_100] }
  76. ]
  77. end
  78. # GET /admin/analytics/content/export
  79. def export
  80. @period = params[:period] || 'month'
  81. @content_type = params[:content_type] || 'all' # all, posts, pages
  82. case @content_type
  83. when 'posts'
  84. @content = Post.published.includes(:pageviews)
  85. when 'pages'
  86. @content = Page.published.includes(:pageviews)
  87. else
  88. @content = Post.published.includes(:pageviews) + Page.published.includes(:pageviews)
  89. end
  90. respond_to do |format|
  91. format.csv do
  92. send_data generate_csv(@content, @period),
  93. filename: "content-analytics-#{@content_type}-#{@period}-#{Date.today}.csv",
  94. type: 'text/csv',
  95. disposition: 'attachment'
  96. end
  97. end
  98. end
  99. private
  100. def generate_csv(content, period)
  101. require 'csv'
  102. CSV.generate(headers: true) do |csv|
  103. csv << [
  104. 'Content Type', 'Title', 'Slug', 'Published Date', 'Total Views',
  105. 'Unique Readers', 'Avg Reading Time', 'Avg Completion Rate',
  106. 'Engagement Score', 'URL'
  107. ]
  108. content.each do |item|
  109. range = period_range(period)
  110. pageviews = item.pageviews.where(visited_at: range).non_bot.consented_only
  111. csv << [
  112. item.class.name,
  113. item.title,
  114. item.slug,
  115. item.published_at&.strftime('%Y-%m-%d'),
  116. pageviews.count,
  117. pageviews.distinct.count(:session_id),
  118. pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  119. pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  120. ContentAnalyticsService.calculate_engagement_score(pageviews),
  121. Rails.application.routes.url_helpers.url_for(item)
  122. ]
  123. end
  124. end
  125. end
  126. def period_range(period)
  127. case period.to_sym
  128. when :today
  129. Time.current.beginning_of_day..Time.current.end_of_day
  130. when :week
  131. 1.week.ago..Time.current
  132. when :month
  133. 1.month.ago..Time.current
  134. when :year
  135. 1.year.ago..Time.current
  136. else
  137. 1.month.ago..Time.current
  138. end
  139. end
  140. end

app/controllers/admin/content_types_controller.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ContentTypesController < Admin::BaseController
  2. before_action :set_content_type, only: %i[ show edit update destroy ]
  3. before_action :ensure_admin
  4. # GET /admin/content_types or /admin/content_types.json
  5. def index
  6. @content_types = ContentType.all.ordered
  7. respond_to do |format|
  8. format.html
  9. format.json { render json: content_types_json }
  10. end
  11. end
  12. # GET /admin/content_types/1 or /admin/content_types/1.json
  13. def show
  14. end
  15. # GET /admin/content_types/new
  16. def new
  17. @content_type = ContentType.new
  18. end
  19. # GET /admin/content_types/1/edit
  20. def edit
  21. end
  22. # POST /admin/content_types or /admin/content_types.json
  23. def create
  24. @content_type = ContentType.new(content_type_params)
  25. respond_to do |format|
  26. if @content_type.save
  27. format.html { redirect_to admin_content_types_path, notice: "Content type was successfully created." }
  28. format.json { render :show, status: :created, location: [:admin, @content_type] }
  29. else
  30. format.html { render :new, status: :unprocessable_entity }
  31. format.json { render json: @content_type.errors, status: :unprocessable_entity }
  32. end
  33. end
  34. end
  35. # PATCH/PUT /admin/content_types/1 or /admin/content_types/1.json
  36. def update
  37. respond_to do |format|
  38. if @content_type.update(content_type_params)
  39. format.html { redirect_to admin_content_types_path, notice: "Content type was successfully updated." }
  40. format.json { render :show, status: :ok, location: [:admin, @content_type] }
  41. else
  42. format.html { render :edit, status: :unprocessable_entity }
  43. format.json { render json: @content_type.errors, status: :unprocessable_entity }
  44. end
  45. end
  46. end
  47. # DELETE /admin/content_types/1 or /admin/content_types/1.json
  48. def destroy
  49. # Don't allow deletion of default 'post' type
  50. if @content_type.ident == 'post'
  51. respond_to do |format|
  52. format.html { redirect_to admin_content_types_path, alert: "Cannot delete the default 'post' content type." }
  53. format.json { render json: { error: "Cannot delete default content type" }, status: :unprocessable_entity }
  54. end
  55. return
  56. end
  57. @content_type.destroy!
  58. respond_to do |format|
  59. format.html { redirect_to admin_content_types_path, notice: "Content type was successfully deleted." }
  60. format.json { head :no_content }
  61. end
  62. end
  63. private
  64. # Use callbacks to share common setup or constraints between actions.
  65. def set_content_type
  66. @content_type = ContentType.find(params[:id])
  67. end
  68. # Only allow a list of trusted parameters through.
  69. def content_type_params
  70. params.require(:content_type).permit(
  71. :ident, :label, :singular, :plural, :description, :icon,
  72. :public, :hierarchical, :has_archive, :menu_position,
  73. :rest_base, :active,
  74. supports: [], capabilities: {}
  75. )
  76. end
  77. def content_types_json
  78. @content_types.map do |ct|
  79. {
  80. id: ct.id,
  81. ident: ct.ident,
  82. label: ct.label,
  83. singular: ct.singular,
  84. plural: ct.plural,
  85. icon: ct.icon,
  86. public: ct.public,
  87. hierarchical: ct.hierarchical,
  88. has_archive: ct.has_archive,
  89. posts_count: ct.posts.count,
  90. active: ct.active,
  91. created_at: ct.created_at.strftime("%Y-%m-%d %H:%M")
  92. }
  93. end
  94. end
  95. def ensure_admin
  96. unless current_user&.administrator?
  97. redirect_to admin_root_path, alert: 'You do not have permission to manage content types.'
  98. end
  99. end
  100. end

app/controllers/admin/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

11 relevant lines. 0 lines covered and 11 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::DashboardController < Admin::BaseController
  2. def index
  3. # Content metrics only
  4. @posts_count = Post.count
  5. @published_posts_count = Post.published.count
  6. @pages_count = Page.count
  7. @comments_count = Comment.count
  8. @pending_comments_count = Comment.pending.count
  9. @recent_posts = Post.order(created_at: :desc).limit(5)
  10. @recent_comments = Comment.order(created_at: :desc).limit(5)
  11. end
  12. end

app/controllers/admin/email_logs_controller.rb

0.0% lines covered

100.0% branches covered

22 relevant lines. 0 lines covered and 22 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::EmailLogsController < Admin::BaseController
  2. include Pagy::Backend
  3. def index
  4. @pagy, @email_logs = pagy(EmailLog.recent, items: 50)
  5. @stats = EmailLog.stats
  6. end
  7. def show
  8. @email_log = EmailLog.find(params[:id])
  9. end
  10. def destroy
  11. @email_log = EmailLog.find(params[:id])
  12. @email_log.destroy
  13. redirect_to admin_email_logs_path, notice: 'Email log deleted successfully.'
  14. end
  15. def destroy_all
  16. EmailLog.delete_all
  17. redirect_to admin_email_logs_path, notice: 'All email logs cleared successfully.'
  18. end
  19. def stats
  20. render json: EmailLog.stats
  21. end
  22. end

app/controllers/admin/field_groups_controller.rb

0.0% lines covered

100.0% branches covered

114 relevant lines. 0 lines covered and 114 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::FieldGroupsController < Admin::BaseController
  2. before_action :set_field_group, only: [:show, :edit, :update, :destroy, :toggle, :duplicate]
  3. # GET /admin/field_groups
  4. def index
  5. @field_groups = FieldGroup.ordered
  6. .includes(:custom_fields)
  7. @field_groups = @field_groups.where(active: true) if params[:filter] == 'active'
  8. @field_groups = @field_groups.where(active: false) if params[:filter] == 'inactive'
  9. respond_to do |format|
  10. format.html
  11. format.json {
  12. render json: @field_groups.map { |fg| field_group_json(fg) }
  13. }
  14. end
  15. end
  16. # GET /admin/field_groups/:id
  17. def show
  18. @fields = @field_group.custom_fields.ordered
  19. end
  20. # GET /admin/field_groups/new
  21. def new
  22. @field_group = FieldGroup.new
  23. @field_group.custom_fields.build # Start with one field
  24. end
  25. # GET /admin/field_groups/:id/edit
  26. def edit
  27. @field_group.custom_fields.build if @field_group.custom_fields.empty?
  28. end
  29. # POST /admin/field_groups
  30. def create
  31. @field_group = FieldGroup.new(field_group_params)
  32. if @field_group.save
  33. redirect_to edit_admin_field_group_path(@field_group),
  34. notice: 'Field group created successfully.'
  35. else
  36. render :new, status: :unprocessable_entity
  37. end
  38. end
  39. # PATCH/PUT /admin/field_groups/:id
  40. def update
  41. if @field_group.update(field_group_params)
  42. redirect_to edit_admin_field_group_path(@field_group),
  43. notice: 'Field group updated successfully.'
  44. else
  45. render :edit, status: :unprocessable_entity
  46. end
  47. end
  48. # DELETE /admin/field_groups/:id
  49. def destroy
  50. @field_group.destroy
  51. redirect_to admin_field_groups_path, notice: 'Field group deleted successfully.'
  52. end
  53. # PATCH /admin/field_groups/:id/toggle
  54. def toggle
  55. @field_group.update(active: !@field_group.active)
  56. respond_to do |format|
  57. format.html { redirect_to admin_field_groups_path }
  58. format.json { render json: { active: @field_group.active } }
  59. end
  60. end
  61. # POST /admin/field_groups/:id/duplicate
  62. def duplicate
  63. new_field_group = @field_group.dup
  64. new_field_group.name = "#{@field_group.name} (Copy)"
  65. new_field_group.slug = "#{@field_group.slug}_copy_#{Time.now.to_i}"
  66. if new_field_group.save
  67. # Duplicate fields
  68. @field_group.custom_fields.each do |field|
  69. new_field = field.dup
  70. new_field.field_group = new_field_group
  71. new_field.save
  72. end
  73. redirect_to edit_admin_field_group_path(new_field_group),
  74. notice: 'Field group duplicated successfully.'
  75. else
  76. redirect_to admin_field_groups_path, alert: 'Failed to duplicate field group.'
  77. end
  78. end
  79. # POST /admin/field_groups/reorder
  80. def reorder
  81. params[:order].each_with_index do |id, index|
  82. FieldGroup.find(id).update(position: index)
  83. end
  84. head :ok
  85. end
  86. private
  87. def set_field_group
  88. @field_group = FieldGroup.find(params[:id])
  89. end
  90. def field_group_params
  91. params.require(:field_group).permit(
  92. :name,
  93. :slug,
  94. :description,
  95. :position,
  96. :active,
  97. location_rules: {},
  98. custom_fields_attributes: [
  99. :id,
  100. :name,
  101. :label,
  102. :field_type,
  103. :instructions,
  104. :required,
  105. :default_value,
  106. :position,
  107. :_destroy,
  108. choices: {},
  109. conditional_logic: {},
  110. settings: {}
  111. ]
  112. )
  113. end
  114. def field_group_json(field_group)
  115. {
  116. id: field_group.id,
  117. name: field_group.name,
  118. slug: field_group.slug,
  119. description: field_group.description,
  120. active: field_group.active,
  121. fields_count: field_group.custom_fields.count,
  122. location_rules: field_group.location_rules
  123. }
  124. end
  125. end

app/controllers/admin/fonts_controller.rb

0.0% lines covered

100.0% branches covered

106 relevant lines. 0 lines covered and 106 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::FontsController < Admin::BaseController
  2. before_action :set_font, only: [:show, :edit, :update, :destroy, :toggle, :preview]
  3. # GET /admin/fonts
  4. def index
  5. @fonts = CustomFont.ordered.all
  6. respond_to do |format|
  7. format.html
  8. format.json {
  9. render json: @fonts.map { |f| font_json(f) }
  10. }
  11. end
  12. end
  13. # GET /admin/fonts/:id
  14. def show
  15. end
  16. # GET /admin/fonts/new
  17. def new
  18. @font = CustomFont.new
  19. @font.weights = ['400']
  20. @font.styles = ['normal']
  21. end
  22. # GET /admin/fonts/:id/edit
  23. def edit
  24. end
  25. # POST /admin/fonts
  26. def create
  27. @font = CustomFont.new(font_params)
  28. if @font.save
  29. redirect_to admin_fonts_path, notice: 'Font added successfully.'
  30. else
  31. render :new, status: :unprocessable_entity
  32. end
  33. end
  34. # PATCH/PUT /admin/fonts/:id
  35. def update
  36. if @font.update(font_params)
  37. redirect_to admin_fonts_path, notice: 'Font updated successfully.'
  38. else
  39. render :edit, status: :unprocessable_entity
  40. end
  41. end
  42. # DELETE /admin/fonts/:id
  43. def destroy
  44. @font.destroy
  45. redirect_to admin_fonts_path, notice: 'Font deleted successfully.'
  46. end
  47. # PATCH /admin/fonts/:id/toggle
  48. def toggle
  49. @font.update(active: !@font.active)
  50. respond_to do |format|
  51. format.html { redirect_to admin_fonts_path }
  52. format.json { render json: { active: @font.active } }
  53. end
  54. end
  55. # GET /admin/fonts/:id/preview
  56. def preview
  57. render layout: false
  58. end
  59. # GET /admin/fonts/google
  60. def google
  61. # Popular Google Fonts list
  62. @popular_fonts = [
  63. { name: 'Inter', category: 'sans-serif', popularity: 1 },
  64. { name: 'Roboto', category: 'sans-serif', popularity: 2 },
  65. { name: 'Open Sans', category: 'sans-serif', popularity: 3 },
  66. { name: 'Lato', category: 'sans-serif', popularity: 4 },
  67. { name: 'Montserrat', category: 'sans-serif', popularity: 5 },
  68. { name: 'Poppins', category: 'sans-serif', popularity: 6 },
  69. { name: 'Raleway', category: 'sans-serif', popularity: 7 },
  70. { name: 'Playfair Display', category: 'serif', popularity: 8 },
  71. { name: 'Merriweather', category: 'serif', popularity: 9 },
  72. { name: 'Roboto Mono', category: 'monospace', popularity: 10 }
  73. ]
  74. render layout: false
  75. end
  76. # POST /admin/fonts/add_google
  77. def add_google
  78. font_family = params[:family]
  79. font = CustomFont.create!(
  80. name: font_family,
  81. family: font_family,
  82. source: 'google',
  83. weights: params[:weights] || ['400'],
  84. styles: params[:styles] || ['normal'],
  85. fallback: params[:fallback] || 'sans-serif',
  86. active: true
  87. )
  88. redirect_to admin_fonts_path, notice: "Added #{font_family} from Google Fonts."
  89. end
  90. private
  91. def set_font
  92. @font = CustomFont.find(params[:id])
  93. end
  94. def font_params
  95. params.require(:custom_font).permit(
  96. :name,
  97. :family,
  98. :source,
  99. :url,
  100. :fallback,
  101. :active,
  102. weights: [],
  103. styles: []
  104. )
  105. end
  106. def font_json(font)
  107. {
  108. id: font.id,
  109. name: font.name,
  110. family: font.family,
  111. source: font.source,
  112. weights: font.weights,
  113. styles: font.styles,
  114. active: font.active,
  115. url: font.font_url
  116. }
  117. end
  118. end

app/controllers/admin/gdpr/gdpr_controller.rb

0.0% lines covered

100.0% branches covered

299 relevant lines. 0 lines covered and 299 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Gdpr::GdprController < Admin::BaseController
  2. before_action :set_user, only: [:user_data, :export_user_data, :erase_user_data, :user_consent_history]
  3. before_action :set_export_request, only: [:download_export, :export_status]
  4. before_action :set_erasure_request, only: [:confirm_erasure, :erasure_status]
  5. # GET /admin/gdpr
  6. def index
  7. @users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests)
  8. .order(:email)
  9. .page(params[:page])
  10. .per(25)
  11. @stats = {
  12. total_users: User.count,
  13. pending_exports: PersonalDataExportRequest.where(status: ['pending', 'processing']).count,
  14. pending_erasures: PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
  15. completed_exports: PersonalDataExportRequest.where(status: 'completed').count,
  16. completed_erasures: PersonalDataErasureRequest.where(status: 'completed').count
  17. }
  18. @recent_requests = {
  19. exports: PersonalDataExportRequest.includes(:user).recent.limit(10),
  20. erasures: PersonalDataErasureRequest.includes(:user).recent.limit(10)
  21. }
  22. end
  23. # GET /admin/gdpr/users
  24. def users
  25. @users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests, :user_consents, :posts, :media)
  26. .order(:email)
  27. if params[:search].present?
  28. @users = @users.where("email ILIKE ? OR name ILIKE ?", "%#{params[:search]}%", "%#{params[:search]}%")
  29. end
  30. if params[:filter].present?
  31. case params[:filter]
  32. when 'with_exports'
  33. @users = @users.joins(:personal_data_export_requests)
  34. when 'with_erasures'
  35. @users = @users.joins(:personal_data_erasure_requests)
  36. when 'with_consent'
  37. @users = @users.joins(:user_consents)
  38. end
  39. end
  40. @users = @users.page(params[:page]).per(50)
  41. # Preload comment counts for all users to avoid N+1 queries
  42. user_emails = @users.pluck(:email)
  43. @comment_counts = Comment.where(author_email: user_emails).group(:author_email).count
  44. end
  45. # GET /admin/gdpr/users/:id
  46. def user_data
  47. @export_requests = @user.personal_data_export_requests.recent
  48. @erasure_requests = @user.personal_data_erasure_requests.recent
  49. @consent_records = @user.user_consents.recent
  50. @data_summary = {
  51. posts: @user.posts.count,
  52. pages: @user.pages.count,
  53. comments: Comment.where(author_email: @user.email).count,
  54. media: @user.media.count,
  55. pageviews: Pageview.where(user_id: @user.id).count,
  56. api_tokens: @user.api_tokens.count,
  57. meta_fields: @user.meta_fields.count,
  58. consent_records: @user.user_consents.count
  59. }
  60. @gdpr_status = GdprService.get_user_gdpr_status(@user)
  61. end
  62. # POST /admin/gdpr/users/:id/export
  63. def export_user_data
  64. begin
  65. export_request = GdprService.create_export_request(@user, current_user)
  66. redirect_to admin_gdpr_user_data_path(@user),
  67. notice: "Data export request created successfully. Processing will begin shortly."
  68. rescue => e
  69. redirect_to admin_gdpr_user_data_path(@user),
  70. alert: "Failed to create export request: #{e.message}"
  71. end
  72. end
  73. # GET /admin/gdpr/exports/:id/download
  74. def download_export
  75. unless @export_request.completed?
  76. redirect_to admin_gdpr_user_data_path(@export_request.user),
  77. alert: 'Export is not ready yet. Please wait for processing to complete.'
  78. return
  79. end
  80. unless File.exist?(@export_request.file_path)
  81. redirect_to admin_gdpr_user_data_path(@export_request.user),
  82. alert: 'Export file not found. Please request a new export.'
  83. return
  84. end
  85. send_file @export_request.file_path,
  86. filename: "personal_data_#{@export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
  87. type: 'application/json',
  88. disposition: 'attachment'
  89. end
  90. # POST /admin/gdpr/users/:id/erase
  91. def erase_user_data
  92. begin
  93. erasure_request = GdprService.create_erasure_request(@user, current_user, params[:reason])
  94. redirect_to admin_gdpr_user_data_path(@user),
  95. notice: "Data erasure request created. Confirmation required before processing."
  96. rescue => e
  97. redirect_to admin_gdpr_user_data_path(@user),
  98. alert: "Failed to create erasure request: #{e.message}"
  99. end
  100. end
  101. # POST /admin/gdpr/erasures/:id/confirm
  102. def confirm_erasure
  103. begin
  104. GdprService.confirm_erasure_request(@erasure_request, current_user)
  105. redirect_to admin_gdpr_user_data_path(@erasure_request.user),
  106. notice: "Data erasure confirmed and queued for processing."
  107. rescue => e
  108. redirect_to admin_gdpr_user_data_path(@erasure_request.user),
  109. alert: "Failed to confirm erasure: #{e.message}"
  110. end
  111. end
  112. # GET /admin/gdpr/users/:id/consent
  113. def user_consent_history
  114. @consent_records = @user.user_consents.recent
  115. @consent_types = UserConsent::CONSENT_TYPES
  116. end
  117. # POST /admin/gdpr/users/:id/consent
  118. def record_consent
  119. begin
  120. consent_data = {
  121. granted: params[:granted] == 'true',
  122. consent_text: params[:consent_text],
  123. ip_address: request.remote_ip,
  124. user_agent: request.user_agent
  125. }
  126. GdprService.record_user_consent(@user, params[:consent_type], consent_data)
  127. redirect_to admin_gdpr_user_consent_history_path(@user),
  128. notice: "Consent recorded successfully."
  129. rescue => e
  130. redirect_to admin_gdpr_user_consent_history_path(@user),
  131. alert: "Failed to record consent: #{e.message}"
  132. end
  133. end
  134. # DELETE /admin/gdpr/users/:id/consent/:consent_type
  135. def withdraw_consent
  136. begin
  137. GdprService.withdraw_user_consent(@user, params[:consent_type])
  138. redirect_to admin_gdpr_user_consent_history_path(@user),
  139. notice: "Consent withdrawn successfully."
  140. rescue => e
  141. redirect_to admin_gdpr_user_consent_history_path(@user),
  142. alert: "Failed to withdraw consent: #{e.message}"
  143. end
  144. end
  145. # GET /admin/gdpr/requests
  146. def requests
  147. @export_requests = PersonalDataExportRequest.includes(:user)
  148. .order(created_at: :desc)
  149. .page(params[:export_page])
  150. .per(25)
  151. @erasure_requests = PersonalDataErasureRequest.includes(:user)
  152. .order(created_at: :desc)
  153. .page(params[:erasure_page])
  154. .per(25)
  155. if params[:status].present?
  156. @export_requests = @export_requests.where(status: params[:status])
  157. @erasure_requests = @erasure_requests.where(status: params[:status])
  158. end
  159. if params[:user_search].present?
  160. user_ids = User.where("email ILIKE ?", "%#{params[:user_search]}%").pluck(:id)
  161. @export_requests = @export_requests.where(user_id: user_ids)
  162. @erasure_requests = @erasure_requests.where(user_id: user_ids)
  163. end
  164. end
  165. # GET /admin/gdpr/audit
  166. def audit
  167. @audit_entries = GdprService.get_audit_log(params[:page] || 1, 50)
  168. # Preload audit statistics to avoid queries in view
  169. @audit_stats = {
  170. total_exports: PersonalDataExportRequest.count,
  171. total_erasures: PersonalDataErasureRequest.count,
  172. total_consents: UserConsent.count,
  173. pending_requests: PersonalDataExportRequest.where(status: ['pending', 'processing']).count +
  174. PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count
  175. }
  176. if params[:user_search].present?
  177. # Filter audit entries by user email
  178. @audit_entries = @audit_entries.select { |entry|
  179. entry[:user_email].downcase.include?(params[:user_search].downcase)
  180. }
  181. end
  182. if params[:action_filter].present?
  183. @audit_entries = @audit_entries.select { |entry|
  184. entry[:action].include?(params[:action_filter])
  185. }
  186. end
  187. end
  188. # GET /admin/gdpr/compliance
  189. def compliance
  190. @compliance_stats = {
  191. total_users: User.count,
  192. users_with_consent: User.joins(:user_consents).distinct.count,
  193. pending_requests: PersonalDataExportRequest.where(status: ['pending', 'processing']).count +
  194. PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
  195. completed_requests: PersonalDataExportRequest.where(status: 'completed').count +
  196. PersonalDataErasureRequest.where(status: 'completed').count,
  197. consent_types: UserConsent.group(:consent_type).count,
  198. recent_activity: {
  199. exports_last_7_days: PersonalDataExportRequest.where('created_at > ?', 7.days.ago).count,
  200. erasures_last_7_days: PersonalDataErasureRequest.where('created_at > ?', 7.days.ago).count,
  201. consent_changes_last_7_days: UserConsent.where('granted_at > ?', 7.days.ago).count
  202. }
  203. }
  204. @gdpr_requirements = {
  205. data_export_implemented: true,
  206. data_erasure_implemented: true,
  207. consent_management_implemented: true,
  208. audit_trail_implemented: true,
  209. data_protection_by_design: true,
  210. user_rights_accessible: true
  211. }
  212. end
  213. # GET /admin/gdpr/settings
  214. def settings
  215. @gdpr_settings = {
  216. data_retention_days: SiteSetting.get('gdpr_data_retention_days', 365),
  217. export_auto_delete_days: SiteSetting.get('gdpr_export_auto_delete_days', 7),
  218. erasure_confirmation_required: SiteSetting.get('gdpr_erasure_confirmation_required', true),
  219. consent_required_for_processing: SiteSetting.get('gdpr_consent_required_for_processing', true),
  220. audit_log_retention_days: SiteSetting.get('gdpr_audit_log_retention_days', 2555), # 7 years
  221. anonymize_ip_addresses: SiteSetting.get('gdpr_anonymize_ip_addresses', true)
  222. }
  223. end
  224. # PATCH /admin/gdpr/settings
  225. def update_settings
  226. begin
  227. params[:gdpr_settings].each do |key, value|
  228. SiteSetting.set(key, value)
  229. end
  230. redirect_to admin_gdpr_settings_path,
  231. notice: "GDPR settings updated successfully."
  232. rescue => e
  233. redirect_to admin_gdpr_settings_path,
  234. alert: "Failed to update settings: #{e.message}"
  235. end
  236. end
  237. # POST /admin/gdpr/bulk_export
  238. def bulk_export
  239. user_ids = params[:user_ids] || []
  240. if user_ids.empty?
  241. redirect_to admin_gdpr_users_path,
  242. alert: "Please select users to export."
  243. return
  244. end
  245. users = User.where(id: user_ids)
  246. success_count = 0
  247. error_count = 0
  248. users.each do |user|
  249. begin
  250. GdprService.create_export_request(user, current_user)
  251. success_count += 1
  252. rescue => e
  253. error_count += 1
  254. Rails.logger.error("Bulk export failed for user #{user.id}: #{e.message}")
  255. end
  256. end
  257. if error_count > 0
  258. redirect_to admin_gdpr_users_path,
  259. notice: "Bulk export initiated. #{success_count} successful, #{error_count} failed."
  260. else
  261. redirect_to admin_gdpr_users_path,
  262. notice: "Bulk export initiated for #{success_count} users."
  263. end
  264. end
  265. # GET /admin/gdpr/export_template
  266. def export_template
  267. respond_to do |format|
  268. format.json do
  269. render json: {
  270. template: {
  271. request_info: {
  272. requested_at: Time.current.iso8601,
  273. email: "user@example.com",
  274. export_date: Time.current.iso8601
  275. },
  276. user_profile: {
  277. id: 1,
  278. email: "user@example.com",
  279. name: "User Name",
  280. role: "author",
  281. created_at: Time.current.iso8601,
  282. updated_at: Time.current.iso8601
  283. },
  284. posts: [],
  285. comments: [],
  286. media: [],
  287. subscribers: [],
  288. api_tokens: [],
  289. meta_fields: [],
  290. analytics_data: {},
  291. consent_records: [],
  292. gdpr_requests: {},
  293. metadata: {
  294. total_posts: 0,
  295. total_comments: 0,
  296. export_date: Time.current.iso8601
  297. }
  298. }
  299. }
  300. end
  301. end
  302. end
  303. private
  304. def set_user
  305. @user = User.find(params[:id] || params[:user_id])
  306. rescue ActiveRecord::RecordNotFound
  307. redirect_to admin_gdpr_users_path, alert: 'User not found.'
  308. end
  309. def set_export_request
  310. @export_request = PersonalDataExportRequest.find(params[:id])
  311. rescue ActiveRecord::RecordNotFound
  312. redirect_to admin_gdpr_requests_path, alert: 'Export request not found.'
  313. end
  314. def set_erasure_request
  315. @erasure_request = PersonalDataErasureRequest.find(params[:id])
  316. rescue ActiveRecord::RecordNotFound
  317. redirect_to admin_gdpr_requests_path, alert: 'Erasure request not found.'
  318. end
  319. end

app/controllers/admin/gdpr_controller.rb

0.0% lines covered

100.0% branches covered

291 relevant lines. 0 lines covered and 291 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Gdpr::GdprController < Admin::BaseController
  2. before_action :set_user, only: [:user_data, :export_user_data, :erase_user_data, :user_consent_history]
  3. before_action :set_export_request, only: [:download_export, :export_status]
  4. before_action :set_erasure_request, only: [:confirm_erasure, :erasure_status]
  5. # GET /admin/gdpr
  6. def index
  7. @users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests)
  8. .order(:email)
  9. .page(params[:page])
  10. .per(25)
  11. @stats = {
  12. total_users: User.count,
  13. pending_exports: PersonalDataExportRequest.where(status: ['pending', 'processing']).count,
  14. pending_erasures: PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
  15. completed_exports: PersonalDataExportRequest.where(status: 'completed').count,
  16. completed_erasures: PersonalDataErasureRequest.where(status: 'completed').count
  17. }
  18. @recent_requests = {
  19. exports: PersonalDataExportRequest.includes(:user).recent.limit(10),
  20. erasures: PersonalDataErasureRequest.includes(:user).recent.limit(10)
  21. }
  22. end
  23. # GET /admin/gdpr/users
  24. def users
  25. @users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests, :user_consents)
  26. .order(:email)
  27. .page(params[:page])
  28. .per(50)
  29. if params[:search].present?
  30. @users = @users.where("email ILIKE ? OR name ILIKE ?", "%#{params[:search]}%", "%#{params[:search]}%")
  31. end
  32. if params[:filter].present?
  33. case params[:filter]
  34. when 'with_exports'
  35. @users = @users.joins(:personal_data_export_requests)
  36. when 'with_erasures'
  37. @users = @users.joins(:personal_data_erasure_requests)
  38. when 'with_consent'
  39. @users = @users.joins(:user_consents)
  40. end
  41. end
  42. end
  43. # GET /admin/gdpr/users/:id
  44. def user_data
  45. @export_requests = @user.personal_data_export_requests.recent
  46. @erasure_requests = @user.personal_data_erasure_requests.recent
  47. @consent_records = @user.user_consents.recent
  48. @data_summary = {
  49. posts: @user.posts.count,
  50. pages: @user.pages.count,
  51. comments: Comment.where(author_email: @user.email).count,
  52. media: @user.media.count,
  53. pageviews: Pageview.where(user_id: @user.id).count,
  54. api_tokens: @user.api_tokens.count,
  55. meta_fields: @user.meta_fields.count,
  56. consent_records: @user.user_consents.count
  57. }
  58. @gdpr_status = GdprService.get_user_gdpr_status(@user)
  59. end
  60. # POST /admin/gdpr/users/:id/export
  61. def export_user_data
  62. begin
  63. export_request = GdprService.create_export_request(@user, current_user)
  64. redirect_to admin_gdpr_user_data_path(@user),
  65. notice: "Data export request created successfully. Processing will begin shortly."
  66. rescue => e
  67. redirect_to admin_gdpr_user_data_path(@user),
  68. alert: "Failed to create export request: #{e.message}"
  69. end
  70. end
  71. # GET /admin/gdpr/exports/:id/download
  72. def download_export
  73. unless @export_request.completed?
  74. redirect_to admin_gdpr_user_data_path(@export_request.user),
  75. alert: 'Export is not ready yet. Please wait for processing to complete.'
  76. return
  77. end
  78. unless File.exist?(@export_request.file_path)
  79. redirect_to admin_gdpr_user_data_path(@export_request.user),
  80. alert: 'Export file not found. Please request a new export.'
  81. return
  82. end
  83. send_file @export_request.file_path,
  84. filename: "personal_data_#{@export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
  85. type: 'application/json',
  86. disposition: 'attachment'
  87. end
  88. # POST /admin/gdpr/users/:id/erase
  89. def erase_user_data
  90. begin
  91. erasure_request = GdprService.create_erasure_request(@user, current_user, params[:reason])
  92. redirect_to admin_gdpr_user_data_path(@user),
  93. notice: "Data erasure request created. Confirmation required before processing."
  94. rescue => e
  95. redirect_to admin_gdpr_user_data_path(@user),
  96. alert: "Failed to create erasure request: #{e.message}"
  97. end
  98. end
  99. # POST /admin/gdpr/erasures/:id/confirm
  100. def confirm_erasure
  101. begin
  102. GdprService.confirm_erasure_request(@erasure_request, current_user)
  103. redirect_to admin_gdpr_user_data_path(@erasure_request.user),
  104. notice: "Data erasure confirmed and queued for processing."
  105. rescue => e
  106. redirect_to admin_gdpr_user_data_path(@erasure_request.user),
  107. alert: "Failed to confirm erasure: #{e.message}"
  108. end
  109. end
  110. # GET /admin/gdpr/users/:id/consent
  111. def user_consent_history
  112. @consent_records = @user.user_consents.recent
  113. @consent_types = UserConsent::CONSENT_TYPES
  114. end
  115. # POST /admin/gdpr/users/:id/consent
  116. def record_consent
  117. begin
  118. consent_data = {
  119. granted: params[:granted] == 'true',
  120. consent_text: params[:consent_text],
  121. ip_address: request.remote_ip,
  122. user_agent: request.user_agent
  123. }
  124. GdprService.record_user_consent(@user, params[:consent_type], consent_data)
  125. redirect_to admin_gdpr_user_consent_history_path(@user),
  126. notice: "Consent recorded successfully."
  127. rescue => e
  128. redirect_to admin_gdpr_user_consent_history_path(@user),
  129. alert: "Failed to record consent: #{e.message}"
  130. end
  131. end
  132. # DELETE /admin/gdpr/users/:id/consent/:consent_type
  133. def withdraw_consent
  134. begin
  135. GdprService.withdraw_user_consent(@user, params[:consent_type])
  136. redirect_to admin_gdpr_user_consent_history_path(@user),
  137. notice: "Consent withdrawn successfully."
  138. rescue => e
  139. redirect_to admin_gdpr_user_consent_history_path(@user),
  140. alert: "Failed to withdraw consent: #{e.message}"
  141. end
  142. end
  143. # GET /admin/gdpr/requests
  144. def requests
  145. @export_requests = PersonalDataExportRequest.includes(:user)
  146. .order(created_at: :desc)
  147. .page(params[:export_page])
  148. .per(25)
  149. @erasure_requests = PersonalDataErasureRequest.includes(:user)
  150. .order(created_at: :desc)
  151. .page(params[:erasure_page])
  152. .per(25)
  153. if params[:status].present?
  154. @export_requests = @export_requests.where(status: params[:status])
  155. @erasure_requests = @erasure_requests.where(status: params[:status])
  156. end
  157. if params[:user_search].present?
  158. user_ids = User.where("email ILIKE ?", "%#{params[:user_search]}%").pluck(:id)
  159. @export_requests = @export_requests.where(user_id: user_ids)
  160. @erasure_requests = @erasure_requests.where(user_id: user_ids)
  161. end
  162. end
  163. # GET /admin/gdpr/audit
  164. def audit
  165. @audit_entries = GdprService.get_audit_log(params[:page] || 1, 50)
  166. if params[:user_search].present?
  167. # Filter audit entries by user email
  168. @audit_entries = @audit_entries.select { |entry|
  169. entry[:user_email].downcase.include?(params[:user_search].downcase)
  170. }
  171. end
  172. if params[:action_filter].present?
  173. @audit_entries = @audit_entries.select { |entry|
  174. entry[:action].include?(params[:action_filter])
  175. }
  176. end
  177. end
  178. # GET /admin/gdpr/compliance
  179. def compliance
  180. @compliance_stats = {
  181. total_users: User.count,
  182. users_with_consent: User.joins(:user_consents).distinct.count,
  183. pending_requests: PersonalDataExportRequest.where(status: ['pending', 'processing']).count +
  184. PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
  185. completed_requests: PersonalDataExportRequest.where(status: 'completed').count +
  186. PersonalDataErasureRequest.where(status: 'completed').count,
  187. consent_types: UserConsent.group(:consent_type).count,
  188. recent_activity: {
  189. exports_last_7_days: PersonalDataExportRequest.where('created_at > ?', 7.days.ago).count,
  190. erasures_last_7_days: PersonalDataErasureRequest.where('created_at > ?', 7.days.ago).count,
  191. consent_changes_last_7_days: UserConsent.where('granted_at > ?', 7.days.ago).count
  192. }
  193. }
  194. @gdpr_requirements = {
  195. data_export_implemented: true,
  196. data_erasure_implemented: true,
  197. consent_management_implemented: true,
  198. audit_trail_implemented: true,
  199. data_protection_by_design: true,
  200. user_rights_accessible: true
  201. }
  202. end
  203. # GET /admin/gdpr/settings
  204. def settings
  205. @gdpr_settings = {
  206. data_retention_days: SiteSetting.get('gdpr_data_retention_days', 365),
  207. export_auto_delete_days: SiteSetting.get('gdpr_export_auto_delete_days', 7),
  208. erasure_confirmation_required: SiteSetting.get('gdpr_erasure_confirmation_required', true),
  209. consent_required_for_processing: SiteSetting.get('gdpr_consent_required_for_processing', true),
  210. audit_log_retention_days: SiteSetting.get('gdpr_audit_log_retention_days', 2555), # 7 years
  211. anonymize_ip_addresses: SiteSetting.get('gdpr_anonymize_ip_addresses', true)
  212. }
  213. end
  214. # PATCH /admin/gdpr/settings
  215. def update_settings
  216. begin
  217. params[:gdpr_settings].each do |key, value|
  218. SiteSetting.set(key, value)
  219. end
  220. redirect_to admin_gdpr_settings_path,
  221. notice: "GDPR settings updated successfully."
  222. rescue => e
  223. redirect_to admin_gdpr_settings_path,
  224. alert: "Failed to update settings: #{e.message}"
  225. end
  226. end
  227. # POST /admin/gdpr/bulk_export
  228. def bulk_export
  229. user_ids = params[:user_ids] || []
  230. if user_ids.empty?
  231. redirect_to admin_gdpr_users_path,
  232. alert: "Please select users to export."
  233. return
  234. end
  235. users = User.where(id: user_ids)
  236. success_count = 0
  237. error_count = 0
  238. users.each do |user|
  239. begin
  240. GdprService.create_export_request(user, current_user)
  241. success_count += 1
  242. rescue => e
  243. error_count += 1
  244. Rails.logger.error("Bulk export failed for user #{user.id}: #{e.message}")
  245. end
  246. end
  247. if error_count > 0
  248. redirect_to admin_gdpr_users_path,
  249. notice: "Bulk export initiated. #{success_count} successful, #{error_count} failed."
  250. else
  251. redirect_to admin_gdpr_users_path,
  252. notice: "Bulk export initiated for #{success_count} users."
  253. end
  254. end
  255. # GET /admin/gdpr/export_template
  256. def export_template
  257. respond_to do |format|
  258. format.json do
  259. render json: {
  260. template: {
  261. request_info: {
  262. requested_at: Time.current.iso8601,
  263. email: "user@example.com",
  264. export_date: Time.current.iso8601
  265. },
  266. user_profile: {
  267. id: 1,
  268. email: "user@example.com",
  269. name: "User Name",
  270. role: "author",
  271. created_at: Time.current.iso8601,
  272. updated_at: Time.current.iso8601
  273. },
  274. posts: [],
  275. comments: [],
  276. media: [],
  277. subscribers: [],
  278. api_tokens: [],
  279. meta_fields: [],
  280. analytics_data: {},
  281. consent_records: [],
  282. gdpr_requests: {},
  283. metadata: {
  284. total_posts: 0,
  285. total_comments: 0,
  286. export_date: Time.current.iso8601
  287. }
  288. }
  289. }
  290. end
  291. end
  292. end
  293. private
  294. def set_user
  295. @user = User.find(params[:id] || params[:user_id])
  296. rescue ActiveRecord::RecordNotFound
  297. redirect_to admin_gdpr_users_path, alert: 'User not found.'
  298. end
  299. def set_export_request
  300. @export_request = PersonalDataExportRequest.find(params[:id])
  301. rescue ActiveRecord::RecordNotFound
  302. redirect_to admin_gdpr_requests_path, alert: 'Export request not found.'
  303. end
  304. def set_erasure_request
  305. @erasure_request = PersonalDataErasureRequest.find(params[:id])
  306. rescue ActiveRecord::RecordNotFound
  307. redirect_to admin_gdpr_requests_path, alert: 'Erasure request not found.'
  308. end
  309. end

app/controllers/admin/geolocation_settings_controller.rb

0.0% lines covered

100.0% branches covered

144 relevant lines. 0 lines covered and 144 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::GeolocationSettingsController < Admin::BaseController
  2. before_action :load_geolocation_settings
  3. def show
  4. @geolocation_service = GeolocationService.instance
  5. @maxmind_updater = MaxmindUpdaterService.instance
  6. @database_info = @maxmind_updater.database_info
  7. @pronviders = GeolocationService::PROVIDERS
  8. end
  9. def update
  10. begin
  11. # Update geolocation settings
  12. geolocation_params.each do |key, value|
  13. SiteSetting.set(key, value)
  14. end
  15. # Handle MaxMind database updates
  16. if params[:update_databases].present?
  17. results = @maxmind_updater.update_all_databases
  18. flash[:notice] = "Geolocation settings updated. Database update results: #{format_update_results(results)}"
  19. else
  20. flash[:notice] = "Geolocation settings updated successfully"
  21. end
  22. redirect_to admin_geolocation_settings_path
  23. rescue => e
  24. flash[:alert] = "Failed to update settings: #{e.message}"
  25. redirect_to admin_geolocation_settings_path
  26. end
  27. end
  28. def test_lookup
  29. ip_address = params[:test_ip] || '8.8.8.8'
  30. result = @geolocation_service.test_lookup(ip_address)
  31. render json: result
  32. end
  33. def update_maxmind
  34. type = params[:type] || 'country'
  35. result = @maxmind_updater.update_database(type)
  36. render json: result
  37. end
  38. def test_connection
  39. result = @maxmind_updater.test_connection
  40. render json: result
  41. end
  42. def schedule_auto_update
  43. result = @maxmind_updater.schedule_auto_update
  44. render json: result
  45. end
  46. private
  47. def load_geolocation_settings
  48. @settings = {
  49. # Geolocation provider settings
  50. geolocation_provider: SiteSetting.get('geolocation_provider', 'maxmind'),
  51. geolocation_enabled: SiteSetting.get('geolocation_enabled', false),
  52. # MaxMind settings
  53. maxmind_license_key: SiteSetting.get('maxmind_license_key', ''),
  54. maxmind_auto_update: SiteSetting.get('maxmind_auto_update', false),
  55. maxmind_auto_update_frequency: SiteSetting.get('maxmind_auto_update_frequency', 'weekly'),
  56. # IP-API settings
  57. geolocation_ipapi_enabled: SiteSetting.get('geolocation_ipapi_enabled', false),
  58. # IPInfo settings
  59. geolocation_ipinfo_enabled: SiteSetting.get('geolocation_ipinfo_enabled', false),
  60. geolocation_ipinfo_api_key: SiteSetting.get('geolocation_ipinfo_api_key', ''),
  61. # IP Geolocation settings
  62. geolocation_ipgeolocation_enabled: SiteSetting.get('geolocation_ipgeolocation_enabled', false),
  63. geolocation_ipgeolocation_api_key: SiteSetting.get('geolocation_ipgeolocation_api_key', ''),
  64. # Abstract API settings
  65. geolocation_abstract_enabled: SiteSetting.get('geolocation_abstract_enabled', false),
  66. geolocation_abstract_api_key: SiteSetting.get('geolocation_abstract_api_key', ''),
  67. # Privacy settings (GDPR-friendly defaults)
  68. geolocation_anonymize_ip: SiteSetting.get('geolocation_anonymize_ip', true),
  69. geolocation_store_full_ip: SiteSetting.get('geolocation_store_full_ip', false),
  70. geolocation_require_consent: SiteSetting.get('geolocation_require_consent', true),
  71. geolocation_consent_message: SiteSetting.get('geolocation_consent_message', ''),
  72. geolocation_legal_basis: SiteSetting.get('geolocation_legal_basis', 'consent'),
  73. geolocation_data_retention_days: SiteSetting.get('geolocation_data_retention_days', 90),
  74. geolocation_auto_delete: SiteSetting.get('geolocation_auto_delete', true),
  75. # Data collection controls (GDPR-friendly defaults)
  76. geolocation_collect_country: SiteSetting.get('geolocation_collect_country', true),
  77. geolocation_collect_region: SiteSetting.get('geolocation_collect_region', false),
  78. geolocation_collect_city: SiteSetting.get('geolocation_collect_city', false),
  79. geolocation_collect_coordinates: SiteSetting.get('geolocation_collect_coordinates', false),
  80. # Power user settings (disabled by default)
  81. geolocation_full_power_mode: SiteSetting.get('geolocation_full_power_mode', false),
  82. geolocation_debug_mode: SiteSetting.get('geolocation_debug_mode', false),
  83. geolocation_precision_mode: SiteSetting.get('geolocation_precision_mode', false),
  84. # Fallback settings
  85. geolocation_fallback_enabled: SiteSetting.get('geolocation_fallback_enabled', true),
  86. geolocation_cache_duration: SiteSetting.get('geolocation_cache_duration', 24) # hours
  87. }
  88. end
  89. def geolocation_params
  90. params.require(:settings).permit(
  91. :geolocation_provider,
  92. :geolocation_enabled,
  93. :maxmind_license_key,
  94. :maxmind_auto_update,
  95. :maxmind_auto_update_frequency,
  96. :geolocation_ipapi_enabled,
  97. :geolocation_ipinfo_enabled,
  98. :geolocation_ipinfo_api_key,
  99. :geolocation_ipgeolocation_enabled,
  100. :geolocation_ipgeolocation_api_key,
  101. :geolocation_abstract_enabled,
  102. :geolocation_abstract_api_key,
  103. :geolocation_anonymize_ip,
  104. :geolocation_store_full_ip,
  105. :geolocation_require_consent,
  106. :geolocation_consent_message,
  107. :geolocation_legal_basis,
  108. :geolocation_data_retention_days,
  109. :geolocation_auto_delete,
  110. :geolocation_collect_country,
  111. :geolocation_collect_region,
  112. :geolocation_collect_city,
  113. :geolocation_collect_coordinates,
  114. :geolocation_full_power_mode,
  115. :geolocation_debug_mode,
  116. :geolocation_precision_mode,
  117. :geolocation_fallback_enabled,
  118. :geolocation_cache_duration
  119. )
  120. end
  121. def format_update_results(results)
  122. messages = []
  123. results.each do |type, result|
  124. status = result[:success] ? '✓' : '✗'
  125. messages << "#{status} #{type.capitalize}: #{result[:message]}"
  126. end
  127. messages.join(', ')
  128. end
  129. # POST /admin/settings/geolocation/schedule_auto_update
  130. def schedule_auto_update
  131. frequency = params[:frequency] || 'weekly'
  132. begin
  133. MaxmindUpdaterService.schedule_auto_update(frequency)
  134. redirect_to admin_geolocation_settings_path, notice: "MaxMind auto-update scheduled for #{frequency} updates."
  135. rescue => e
  136. redirect_to admin_geolocation_settings_path, alert: "Failed to schedule auto-update: #{e.message}"
  137. end
  138. end
  139. # DELETE /admin/settings/geolocation/disable_auto_update
  140. def disable_auto_update
  141. begin
  142. MaxmindUpdaterService.disable_auto_update
  143. redirect_to admin_geolocation_settings_path, notice: "MaxMind auto-update disabled."
  144. rescue => e
  145. redirect_to admin_geolocation_settings_path, alert: "Failed to disable auto-update: #{e.message}"
  146. end
  147. end
  148. # GET /admin/settings/geolocation/schedule_status
  149. def schedule_status
  150. schedule_info = MaxmindUpdaterService.get_update_schedule_info
  151. render json: {
  152. enabled: schedule_info[:enabled],
  153. frequency: schedule_info[:frequency],
  154. next_run: schedule_info[:next_run]&.strftime('%Y-%m-%d %H:%M:%S'),
  155. last_run: schedule_info[:last_run]&.strftime('%Y-%m-%d %H:%M:%S'),
  156. cron_schedule: schedule_info[:cron_schedule]
  157. }
  158. end
  159. end

app/controllers/admin/image_optimization_analytics_controller.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ImageOptimizationAnalyticsController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/media/optimization_analytics
  4. def index
  5. @stats = calculate_overview_stats
  6. @recent_optimizations = ImageOptimizationLog.recent.limit(50).includes(:medium, :upload, :user)
  7. @compression_level_stats = ImageOptimizationLog.compression_level_stats
  8. @optimization_type_stats = ImageOptimizationLog.optimization_type_stats
  9. @daily_stats = ImageOptimizationLog.daily_stats(30)
  10. end
  11. # GET /admin/media/optimization_analytics/report
  12. def report
  13. start_date = params[:start_date]&.to_date || 30.days.ago.to_date
  14. end_date = params[:end_date]&.to_date || Date.current
  15. @report = ImageOptimizationLog.generate_report(start_date, end_date)
  16. @start_date = start_date
  17. @end_date = end_date
  18. respond_to do |format|
  19. format.html
  20. format.json { render json: @report }
  21. format.csv do
  22. csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
  23. send_data csv_data,
  24. filename: "image_optimization_report_#{start_date}_to_#{end_date}.csv",
  25. type: 'text/csv'
  26. end
  27. end
  28. end
  29. # GET /admin/media/optimization_analytics/failed
  30. def failed
  31. @failed_optimizations = ImageOptimizationLog.failed_optimizations
  32. .includes(:medium, :upload, :user)
  33. .page(params[:page])
  34. .per(20)
  35. end
  36. # GET /admin/media/optimization_analytics/top_savings
  37. def top_savings
  38. @top_savings = ImageOptimizationLog.top_savings(50).includes(:medium, :upload, :user)
  39. end
  40. # GET /admin/media/optimization_analytics/user_stats
  41. def user_stats
  42. @user_stats = ImageOptimizationLog.user_stats
  43. @top_users = @user_stats.sort_by { |_, count| -count }.first(20)
  44. end
  45. # GET /admin/media/optimization_analytics/tenant_stats
  46. def tenant_stats
  47. @tenant_stats = ImageOptimizationLog.tenant_stats
  48. @top_tenants = @tenant_stats.sort_by { |_, count| -count }.first(20)
  49. end
  50. # GET /admin/media/optimization_analytics/compression_levels
  51. def compression_levels
  52. @compression_levels = ImageOptimizationService.available_compression_levels
  53. @level_stats = ImageOptimizationLog.compression_level_stats
  54. end
  55. # GET /admin/media/optimization_analytics/performance
  56. def performance
  57. @avg_processing_time = ImageOptimizationLog.average_processing_time
  58. @avg_size_reduction = ImageOptimizationLog.average_size_reduction
  59. @total_processing_time = ImageOptimizationLog.total_processing_time
  60. @total_bytes_saved = ImageOptimizationLog.total_bytes_saved
  61. end
  62. # DELETE /admin/media/optimization_analytics/clear_logs
  63. def clear_logs
  64. if params[:confirm] == 'yes'
  65. ImageOptimizationLog.delete_all
  66. redirect_to admin_image_optimization_analytics_index_path,
  67. notice: 'All optimization logs have been cleared.'
  68. else
  69. redirect_to admin_image_optimization_analytics_index_path,
  70. alert: 'Log clearing cancelled. Use confirm=yes to clear logs.'
  71. end
  72. end
  73. # GET /admin/media/optimization_analytics/export
  74. def export
  75. start_date = params[:start_date]&.to_date || 30.days.ago.to_date
  76. end_date = params[:end_date]&.to_date || Date.current
  77. csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
  78. send_data csv_data,
  79. filename: "image_optimization_export_#{start_date}_to_#{end_date}.csv",
  80. type: 'text/csv'
  81. end
  82. private
  83. def calculate_overview_stats
  84. total_bytes_saved = ImageOptimizationLog.total_bytes_saved || 0
  85. avg_reduction = ImageOptimizationLog.average_size_reduction || 0
  86. avg_processing = ImageOptimizationLog.average_processing_time || 0
  87. {
  88. total_optimizations: ImageOptimizationLog.count,
  89. successful_optimizations: ImageOptimizationLog.successful.count,
  90. failed_optimizations: ImageOptimizationLog.failed.count,
  91. skipped_optimizations: ImageOptimizationLog.skipped.count,
  92. total_bytes_saved: total_bytes_saved,
  93. total_size_saved_mb: (total_bytes_saved / 1024.0 / 1024.0).round(2),
  94. average_size_reduction: avg_reduction.round(2),
  95. average_processing_time: avg_processing.round(3),
  96. today_optimizations: ImageOptimizationLog.today.count,
  97. this_week_optimizations: ImageOptimizationLog.this_week.count,
  98. this_month_optimizations: ImageOptimizationLog.this_month.count
  99. }
  100. end
  101. end

app/controllers/admin/integrations_controller.rb

0.0% lines covered

100.0% branches covered

131 relevant lines. 0 lines covered and 131 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::IntegrationsController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/integrations
  4. def index
  5. @integration_plugins = Plugin.where(name: integration_plugin_names).order(:name)
  6. @available_integrations = available_integrations_list
  7. end
  8. # GET /admin/integrations/uploadcare
  9. def uploadcare
  10. @plugin = Plugin.find_by(name: 'Uploadcare')
  11. unless @plugin
  12. redirect_to admin_integrations_path, alert: 'Uploadcare plugin not found. Please install it first.'
  13. return
  14. end
  15. # Load plugin instance
  16. @plugin_instance = load_plugin_instance(@plugin)
  17. @dashboard_url = @plugin_instance&.dashboard_url
  18. @widget_config = @plugin_instance&.widget_config || {}
  19. @enabled = @plugin_instance&.enabled? || false
  20. end
  21. # GET /admin/integrations/:name
  22. def show
  23. integration_name = params[:name]&.titleize
  24. @plugin = Plugin.find_by(name: integration_name)
  25. unless @plugin
  26. redirect_to admin_integrations_path, alert: "#{integration_name} integration not found."
  27. return
  28. end
  29. @plugin_instance = load_plugin_instance(@plugin)
  30. # Redirect to specific integration view if available
  31. if respond_to?("#{params[:name]}_integration", true)
  32. send("#{params[:name]}_integration")
  33. else
  34. render :show
  35. end
  36. end
  37. private
  38. def ensure_admin
  39. unless current_user&.administrator?
  40. redirect_to root_path, alert: 'Access denied. Administrator privileges required.'
  41. end
  42. end
  43. def integration_plugin_names
  44. [
  45. 'Uploadcare',
  46. 'Cloudinary',
  47. 'AWS S3',
  48. 'Google Analytics',
  49. 'Mailchimp',
  50. 'Stripe',
  51. 'SendGrid',
  52. 'Twilio',
  53. 'Slack'
  54. ]
  55. end
  56. def available_integrations_list
  57. [
  58. {
  59. name: 'Uploadcare',
  60. description: 'Professional media management and CDN',
  61. icon: '📸',
  62. category: 'Media',
  63. status: plugin_status('Uploadcare'),
  64. url: uploadcare_admin_integrations_path,
  65. features: ['File Upload Widget', 'CDN Delivery', 'Image Transformations', 'Dashboard Integration']
  66. },
  67. {
  68. name: 'Cloudinary',
  69. description: 'Cloud-based image and video management',
  70. icon: '☁️',
  71. category: 'Media',
  72. status: 'available',
  73. url: '#',
  74. features: ['Image/Video Upload', 'AI-powered Transformations', 'DAM', 'CDN']
  75. },
  76. {
  77. name: 'AWS S3',
  78. description: 'Amazon S3 storage integration',
  79. icon: '🪣',
  80. category: 'Storage',
  81. status: 'available',
  82. url: '#',
  83. features: ['Object Storage', 'Versioning', 'Backup', 'CloudFront CDN']
  84. },
  85. {
  86. name: 'Google Analytics',
  87. description: 'Web analytics and reporting',
  88. icon: '📊',
  89. category: 'Analytics',
  90. status: 'available',
  91. url: '#',
  92. features: ['GA4 Tracking', 'Custom Events', 'Reports', 'User Behavior']
  93. },
  94. {
  95. name: 'Mailchimp',
  96. description: 'Email marketing and automation',
  97. icon: '📧',
  98. category: 'Marketing',
  99. status: 'available',
  100. url: '#',
  101. features: ['Email Campaigns', 'Lists', 'Automation', 'Reports']
  102. },
  103. {
  104. name: 'Stripe',
  105. description: 'Payment processing',
  106. icon: '💳',
  107. category: 'Payments',
  108. status: 'available',
  109. url: '#',
  110. features: ['Online Payments', 'Subscriptions', 'Invoicing', 'Webhooks']
  111. }
  112. ]
  113. end
  114. def plugin_status(plugin_name)
  115. plugin = Plugin.find_by(name: plugin_name)
  116. return 'not_installed' unless plugin
  117. return 'active' if plugin.active?
  118. 'installed'
  119. end
  120. def load_plugin_instance(plugin)
  121. # Try to get from plugin system
  122. instance = Railspress::PluginSystem.get_plugin(plugin.name.underscore) rescue nil
  123. return instance if instance
  124. # Try to load manually
  125. plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
  126. if File.exist?(plugin_path)
  127. begin
  128. load plugin_path
  129. plugin_class_name = plugin.name.classify
  130. plugin_class = plugin_class_name.constantize rescue nil
  131. instance = plugin_class.new if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
  132. rescue => e
  133. Rails.logger.error "Failed to load plugin: #{e.message}"
  134. end
  135. end
  136. instance
  137. end
  138. end

app/controllers/admin/logs_controller.rb

0.0% lines covered

100.0% branches covered

111 relevant lines. 0 lines covered and 111 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::LogsController < Admin::BaseController
  2. # GET /admin/logs
  3. def index
  4. @log_files = available_log_files
  5. @current_log = params[:file] || 'development'
  6. end
  7. # GET /admin/logs/stream
  8. def stream
  9. log_file = params[:file] || 'development'
  10. log_path = Rails.root.join('log', "#{log_file}.log")
  11. unless File.exist?(log_path)
  12. render plain: "Log file not found: #{log_file}.log", status: :not_found
  13. return
  14. end
  15. # Set headers for Server-Sent Events
  16. response.headers['Content-Type'] = 'text/event-stream'
  17. response.headers['Cache-Control'] = 'no-cache'
  18. response.headers['X-Accel-Buffering'] = 'no'
  19. # Stream the log file
  20. lines = params[:lines]&.to_i || 100
  21. self.response_body = Enumerator.new do |yielder|
  22. begin
  23. # Send initial tail of the log
  24. initial_lines = tail_file(log_path, lines)
  25. yielder << "data: #{initial_lines.to_json}\n\n"
  26. # Watch for new lines
  27. File.open(log_path, 'r') do |file|
  28. file.seek(0, IO::SEEK_END)
  29. loop do
  30. line = file.gets
  31. if line
  32. yielder << "data: #{line.to_json}\n\n"
  33. else
  34. sleep 0.5
  35. # Check if file was rotated
  36. file.seek(0, IO::SEEK_CUR)
  37. end
  38. end
  39. end
  40. rescue IOError
  41. # Client disconnected
  42. end
  43. end
  44. end
  45. # GET /admin/logs/download
  46. def download
  47. log_file = params[:file] || 'development'
  48. log_path = Rails.root.join('log', "#{log_file}.log")
  49. unless File.exist?(log_path)
  50. redirect_to admin_logs_path, alert: "Log file not found: #{log_file}.log"
  51. return
  52. end
  53. send_file log_path,
  54. filename: "#{log_file}-#{Date.today}.log",
  55. type: 'text/plain',
  56. disposition: 'attachment'
  57. end
  58. # DELETE /admin/logs/clear
  59. def clear
  60. log_file = params[:file] || 'development'
  61. log_path = Rails.root.join('log', "#{log_file}.log")
  62. if File.exist?(log_path)
  63. File.truncate(log_path, 0)
  64. redirect_to admin_logs_path(file: log_file), notice: "Log file cleared: #{log_file}.log"
  65. else
  66. redirect_to admin_logs_path, alert: "Log file not found: #{log_file}.log"
  67. end
  68. end
  69. # GET /admin/logs/search
  70. def search
  71. log_file = params[:file] || 'development'
  72. query = params[:q]
  73. log_path = Rails.root.join('log', "#{log_file}.log")
  74. unless File.exist?(log_path)
  75. render json: { error: 'Log file not found' }, status: :not_found
  76. return
  77. end
  78. results = []
  79. line_number = 0
  80. File.foreach(log_path) do |line|
  81. line_number += 1
  82. if line.downcase.include?(query.downcase)
  83. results << { line_number: line_number, content: line.strip }
  84. break if results.size >= 100 # Limit results
  85. end
  86. end
  87. render json: { results: results, query: query, count: results.size }
  88. end
  89. private
  90. def available_log_files
  91. log_dir = Rails.root.join('log')
  92. Dir.glob(log_dir.join('*.log')).map do |file|
  93. basename = File.basename(file, '.log')
  94. # Skip weird log files that contain commands or special characters
  95. next if basename.include?('puts') || basename.include?(';') || basename.length > 50
  96. {
  97. name: basename,
  98. path: file,
  99. size: File.size(file),
  100. modified: File.mtime(file)
  101. }
  102. end.compact.sort_by { |f| f[:modified] }.reverse
  103. end
  104. def tail_file(file_path, lines = 100)
  105. content = []
  106. File.open(file_path, 'r') do |file|
  107. file.seek(0, IO::SEEK_END)
  108. position = file.pos
  109. line_count = 0
  110. # Go backwards through the file
  111. while position > 0 && line_count < lines
  112. position -= 1
  113. file.seek(position)
  114. char = file.read(1)
  115. if char == "\n"
  116. line_count += 1
  117. break if line_count >= lines
  118. end
  119. end
  120. content = file.read.split("\n")
  121. end
  122. content.last(lines)
  123. end
  124. end

app/controllers/admin/mcp_settings_controller.rb

0.0% lines covered

100.0% branches covered

345 relevant lines. 0 lines covered and 345 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::McpSettingsController < Admin::BaseController
  2. before_action :authenticate_user!
  3. before_action :ensure_admin
  4. before_action :load_mcp_settings
  5. def show
  6. # Load MCP settings for display
  7. end
  8. def update
  9. if update_mcp_settings
  10. redirect_to admin_mcp_settings_path, notice: 'MCP settings updated successfully.'
  11. else
  12. render :show, status: :unprocessable_entity
  13. end
  14. end
  15. def test_connection
  16. begin
  17. # Test MCP API connection
  18. result = test_mcp_api_connection
  19. if result[:success]
  20. render json: {
  21. success: true,
  22. message: 'MCP API connection successful',
  23. details: result[:details]
  24. }
  25. else
  26. render json: {
  27. success: false,
  28. message: 'MCP API connection failed',
  29. error: result[:error]
  30. }, status: :unprocessable_entity
  31. end
  32. rescue => e
  33. render json: {
  34. success: false,
  35. message: 'MCP API connection test failed',
  36. error: e.message
  37. }, status: :internal_server_error
  38. end
  39. end
  40. def generate_api_key
  41. begin
  42. # Generate a new API key for MCP
  43. new_api_key = SecureRandom.hex(32)
  44. # Update the site setting
  45. SiteSetting.set('mcp_api_key', new_api_key)
  46. render json: {
  47. success: true,
  48. message: 'New API key generated successfully',
  49. api_key: new_api_key
  50. }
  51. rescue => e
  52. render json: {
  53. success: false,
  54. message: 'Failed to generate API key',
  55. error: e.message
  56. }, status: :internal_server_error
  57. end
  58. end
  59. private
  60. def load_mcp_settings
  61. @mcp_settings = {
  62. enabled: SiteSetting.get('mcp_enabled', false),
  63. api_key: SiteSetting.get('mcp_api_key', ''),
  64. max_requests_per_minute: SiteSetting.get('mcp_max_requests_per_minute', 100),
  65. max_requests_per_hour: SiteSetting.get('mcp_max_requests_per_hour', 1000),
  66. max_requests_per_day: SiteSetting.get('mcp_max_requests_per_day', 10000),
  67. allowed_tools: SiteSetting.get('mcp_allowed_tools', 'all'),
  68. allowed_resources: SiteSetting.get('mcp_allowed_resources', 'all'),
  69. allowed_prompts: SiteSetting.get('mcp_allowed_prompts', 'all'),
  70. require_authentication: SiteSetting.get('mcp_require_authentication', true),
  71. log_requests: SiteSetting.get('mcp_log_requests', true),
  72. log_responses: SiteSetting.get('mcp_log_responses', false),
  73. rate_limit_by_ip: SiteSetting.get('mcp_rate_limit_by_ip', true),
  74. rate_limit_by_user: SiteSetting.get('mcp_rate_limit_by_user', true),
  75. enable_streaming: SiteSetting.get('mcp_enable_streaming', true),
  76. max_stream_duration: SiteSetting.get('mcp_max_stream_duration', 300),
  77. enable_cors: SiteSetting.get('mcp_enable_cors', false),
  78. cors_origins: SiteSetting.get('mcp_cors_origins', ''),
  79. enable_webhooks: SiteSetting.get('mcp_enable_webhooks', false),
  80. webhook_url: SiteSetting.get('mcp_webhook_url', ''),
  81. webhook_secret: SiteSetting.get('mcp_webhook_secret', ''),
  82. enable_analytics: SiteSetting.get('mcp_enable_analytics', true),
  83. analytics_retention_days: SiteSetting.get('mcp_analytics_retention_days', 30),
  84. enable_caching: SiteSetting.get('mcp_enable_caching', true),
  85. cache_ttl: SiteSetting.get('mcp_cache_ttl', 300),
  86. enable_compression: SiteSetting.get('mcp_enable_compression', true),
  87. max_request_size: SiteSetting.get('mcp_max_request_size', 1048576),
  88. timeout_seconds: SiteSetting.get('mcp_timeout_seconds', 30),
  89. enable_debug_mode: SiteSetting.get('mcp_enable_debug_mode', false),
  90. debug_log_level: SiteSetting.get('mcp_debug_log_level', 'info'),
  91. enable_metrics: SiteSetting.get('mcp_enable_metrics', true),
  92. metrics_endpoint: SiteSetting.get('mcp_metrics_endpoint', '/api/v1/mcp/metrics'),
  93. enable_health_check: SiteSetting.get('mcp_enable_health_check', true),
  94. health_check_endpoint: SiteSetting.get('mcp_health_check_endpoint', '/api/v1/mcp/health'),
  95. enable_versioning: SiteSetting.get('mcp_enable_versioning', true),
  96. supported_versions: SiteSetting.get('mcp_supported_versions', '2025-03-26'),
  97. enable_deprecation_warnings: SiteSetting.get('mcp_enable_deprecation_warnings', true),
  98. enable_feature_flags: SiteSetting.get('mcp_enable_feature_flags', false),
  99. feature_flags: SiteSetting.get('mcp_feature_flags', '{}'),
  100. enable_audit_log: SiteSetting.get('mcp_enable_audit_log', true),
  101. audit_log_retention_days: SiteSetting.get('mcp_audit_log_retention_days', 90),
  102. enable_security_headers: SiteSetting.get('mcp_enable_security_headers', true),
  103. enable_rate_limit_headers: SiteSetting.get('mcp_enable_rate_limit_headers', true),
  104. enable_error_tracking: SiteSetting.get('mcp_enable_error_tracking', true),
  105. error_tracking_endpoint: SiteSetting.get('mcp_error_tracking_endpoint', ''),
  106. enable_performance_monitoring: SiteSetting.get('mcp_enable_performance_monitoring', true),
  107. performance_threshold_ms: SiteSetting.get('mcp_performance_threshold_ms', 1000),
  108. enable_alerting: SiteSetting.get('mcp_enable_alerting', false),
  109. alert_webhook_url: SiteSetting.get('mcp_alert_webhook_url', ''),
  110. alert_threshold_errors: SiteSetting.get('mcp_alert_threshold_errors', 10),
  111. alert_threshold_response_time: SiteSetting.get('mcp_alert_threshold_response_time', 5000),
  112. enable_backup: SiteSetting.get('mcp_enable_backup', false),
  113. backup_frequency: SiteSetting.get('mcp_backup_frequency', 'daily'),
  114. backup_retention_days: SiteSetting.get('mcp_backup_retention_days', 30),
  115. enable_encryption: SiteSetting.get('mcp_enable_encryption', true),
  116. encryption_key: SiteSetting.get('mcp_encryption_key', ''),
  117. enable_ssl: SiteSetting.get('mcp_enable_ssl', true),
  118. ssl_cert_path: SiteSetting.get('mcp_ssl_cert_path', ''),
  119. ssl_key_path: SiteSetting.get('mcp_ssl_key_path', ''),
  120. enable_oauth: SiteSetting.get('mcp_enable_oauth', false),
  121. oauth_provider: SiteSetting.get('mcp_oauth_provider', ''),
  122. oauth_client_id: SiteSetting.get('mcp_oauth_client_id', ''),
  123. oauth_client_secret: SiteSetting.get('mcp_oauth_client_secret', ''),
  124. oauth_redirect_uri: SiteSetting.get('mcp_oauth_redirect_uri', ''),
  125. enable_jwt: SiteSetting.get('mcp_enable_jwt', false),
  126. jwt_secret: SiteSetting.get('mcp_jwt_secret', ''),
  127. jwt_expiration_hours: SiteSetting.get('mcp_jwt_expiration_hours', 24),
  128. enable_api_versioning: SiteSetting.get('mcp_enable_api_versioning', true),
  129. default_api_version: SiteSetting.get('mcp_default_api_version', 'v1'),
  130. enable_documentation: SiteSetting.get('mcp_enable_documentation', true),
  131. documentation_url: SiteSetting.get('mcp_documentation_url', '/api/v1/mcp/docs'),
  132. enable_sandbox: SiteSetting.get('mcp_enable_sandbox', false),
  133. sandbox_timeout_seconds: SiteSetting.get('mcp_sandbox_timeout_seconds', 60),
  134. enable_playground: SiteSetting.get('mcp_enable_playground', false),
  135. playground_url: SiteSetting.get('mcp_playground_url', '/api/v1/mcp/playground')
  136. }
  137. end
  138. def update_mcp_settings
  139. success = true
  140. # Update each setting
  141. params['mcp_settings']&.each do |key, value|
  142. case key.to_s
  143. when 'enabled'
  144. SiteSetting.set('mcp_enabled', value == '1')
  145. when 'api_key'
  146. SiteSetting.set('mcp_api_key', value) unless value.blank?
  147. when 'max_requests_per_minute'
  148. SiteSetting.set('mcp_max_requests_per_minute', value.to_i)
  149. when 'max_requests_per_hour'
  150. SiteSetting.set('mcp_max_requests_per_hour', value.to_i)
  151. when 'max_requests_per_day'
  152. SiteSetting.set('mcp_max_requests_per_day', value.to_i)
  153. when 'allowed_tools'
  154. SiteSetting.set('mcp_allowed_tools', value)
  155. when 'allowed_resources'
  156. SiteSetting.set('mcp_allowed_resources', value)
  157. when 'allowed_prompts'
  158. SiteSetting.set('mcp_allowed_prompts', value)
  159. when 'require_authentication'
  160. SiteSetting.set('mcp_require_authentication', value == '1')
  161. when 'log_requests'
  162. SiteSetting.set('mcp_log_requests', value == '1')
  163. when 'log_responses'
  164. SiteSetting.set('mcp_log_responses', value == '1')
  165. when 'rate_limit_by_ip'
  166. SiteSetting.set('mcp_rate_limit_by_ip', value == '1')
  167. when 'rate_limit_by_user'
  168. SiteSetting.set('mcp_rate_limit_by_user', value == '1')
  169. when 'enable_streaming'
  170. SiteSetting.set('mcp_enable_streaming', value == '1')
  171. when 'max_stream_duration'
  172. SiteSetting.set('mcp_max_stream_duration', value.to_i)
  173. when 'enable_cors'
  174. SiteSetting.set('mcp_enable_cors', value == '1')
  175. when 'cors_origins'
  176. SiteSetting.set('mcp_cors_origins', value)
  177. when 'enable_webhooks'
  178. SiteSetting.set('mcp_enable_webhooks', value == '1')
  179. when 'webhook_url'
  180. SiteSetting.set('mcp_webhook_url', value)
  181. when 'webhook_secret'
  182. SiteSetting.set('mcp_webhook_secret', value)
  183. when 'enable_analytics'
  184. SiteSetting.set('mcp_enable_analytics', value == '1')
  185. when 'analytics_retention_days'
  186. SiteSetting.set('mcp_analytics_retention_days', value.to_i)
  187. when 'enable_caching'
  188. SiteSetting.set('mcp_enable_caching', value == '1')
  189. when 'cache_ttl'
  190. SiteSetting.set('mcp_cache_ttl', value.to_i)
  191. when 'enable_compression'
  192. SiteSetting.set('mcp_enable_compression', value == '1')
  193. when 'max_request_size'
  194. SiteSetting.set('mcp_max_request_size', value.to_i)
  195. when 'timeout_seconds'
  196. SiteSetting.set('mcp_timeout_seconds', value.to_i)
  197. when 'enable_debug_mode'
  198. SiteSetting.set('mcp_enable_debug_mode', value == '1')
  199. when 'debug_log_level'
  200. SiteSetting.set('mcp_debug_log_level', value)
  201. when 'enable_metrics'
  202. SiteSetting.set('mcp_enable_metrics', value == '1')
  203. when 'metrics_endpoint'
  204. SiteSetting.set('mcp_metrics_endpoint', value)
  205. when 'enable_health_check'
  206. SiteSetting.set('mcp_enable_health_check', value == '1')
  207. when 'health_check_endpoint'
  208. SiteSetting.set('mcp_health_check_endpoint', value)
  209. when 'enable_versioning'
  210. SiteSetting.set('mcp_enable_versioning', value == '1')
  211. when 'supported_versions'
  212. SiteSetting.set('mcp_supported_versions', value)
  213. when 'enable_deprecation_warnings'
  214. SiteSetting.set('mcp_enable_deprecation_warnings', value == '1')
  215. when 'enable_feature_flags'
  216. SiteSetting.set('mcp_enable_feature_flags', value == '1')
  217. when 'feature_flags'
  218. SiteSetting.set('mcp_feature_flags', value)
  219. when 'enable_audit_log'
  220. SiteSetting.set('mcp_enable_audit_log', value == '1')
  221. when 'audit_log_retention_days'
  222. SiteSetting.set('mcp_audit_log_retention_days', value.to_i)
  223. when 'enable_security_headers'
  224. SiteSetting.set('mcp_enable_security_headers', value == '1')
  225. when 'enable_rate_limit_headers'
  226. SiteSetting.set('mcp_enable_rate_limit_headers', value == '1')
  227. when 'enable_error_tracking'
  228. SiteSetting.set('mcp_enable_error_tracking', value == '1')
  229. when 'error_tracking_endpoint'
  230. SiteSetting.set('mcp_error_tracking_endpoint', value)
  231. when 'enable_performance_monitoring'
  232. SiteSetting.set('mcp_enable_performance_monitoring', value == '1')
  233. when 'performance_threshold_ms'
  234. SiteSetting.set('mcp_performance_threshold_ms', value.to_i)
  235. when 'enable_alerting'
  236. SiteSetting.set('mcp_enable_alerting', value == '1')
  237. when 'alert_webhook_url'
  238. SiteSetting.set('mcp_alert_webhook_url', value)
  239. when 'alert_threshold_errors'
  240. SiteSetting.set('mcp_alert_threshold_errors', value.to_i)
  241. when 'alert_threshold_response_time'
  242. SiteSetting.set('mcp_alert_threshold_response_time', value.to_i)
  243. when 'enable_backup'
  244. SiteSetting.set('mcp_enable_backup', value == '1')
  245. when 'backup_frequency'
  246. SiteSetting.set('mcp_backup_frequency', value)
  247. when 'backup_retention_days'
  248. SiteSetting.set('mcp_backup_retention_days', value.to_i)
  249. when 'enable_encryption'
  250. SiteSetting.set('mcp_enable_encryption', value == '1')
  251. when 'encryption_key'
  252. SiteSetting.set('mcp_encryption_key', value) unless value.blank?
  253. when 'enable_ssl'
  254. SiteSetting.set('mcp_enable_ssl', value == '1')
  255. when 'ssl_cert_path'
  256. SiteSetting.set('mcp_ssl_cert_path', value)
  257. when 'ssl_key_path'
  258. SiteSetting.set('mcp_ssl_key_path', value)
  259. when 'enable_oauth'
  260. SiteSetting.set('mcp_enable_oauth', value == '1')
  261. when 'oauth_provider'
  262. SiteSetting.set('mcp_oauth_provider', value)
  263. when 'oauth_client_id'
  264. SiteSetting.set('mcp_oauth_client_id', value)
  265. when 'oauth_client_secret'
  266. SiteSetting.set('mcp_oauth_client_secret', value)
  267. when 'oauth_redirect_uri'
  268. SiteSetting.set('mcp_oauth_redirect_uri', value)
  269. when 'enable_jwt'
  270. SiteSetting.set('mcp_enable_jwt', value == '1')
  271. when 'jwt_secret'
  272. SiteSetting.set('mcp_jwt_secret', value) unless value.blank?
  273. when 'jwt_expiration_hours'
  274. SiteSetting.set('mcp_jwt_expiration_hours', value.to_i)
  275. when 'enable_api_versioning'
  276. SiteSetting.set('mcp_enable_api_versioning', value == '1')
  277. when 'default_api_version'
  278. SiteSetting.set('mcp_default_api_version', value)
  279. when 'enable_documentation'
  280. SiteSetting.set('mcp_enable_documentation', value == '1')
  281. when 'documentation_url'
  282. SiteSetting.set('mcp_documentation_url', value)
  283. when 'enable_sandbox'
  284. SiteSetting.set('mcp_enable_sandbox', value == '1')
  285. when 'sandbox_timeout_seconds'
  286. SiteSetting.set('mcp_sandbox_timeout_seconds', value.to_i)
  287. when 'enable_playground'
  288. SiteSetting.set('mcp_enable_playground', value == '1')
  289. when 'playground_url'
  290. SiteSetting.set('mcp_playground_url', value)
  291. end
  292. end
  293. success
  294. rescue => e
  295. Rails.logger.error "Failed to update MCP settings: #{e.message}"
  296. false
  297. end
  298. def test_mcp_api_connection
  299. # Test the MCP API by making a handshake request
  300. begin
  301. uri = URI("#{request.base_url}/api/v1/mcp/session/handshake")
  302. http = Net::HTTP.new(uri.host, uri.port)
  303. request_obj = Net::HTTP::Post.new(uri)
  304. request_obj['Content-Type'] = 'application/json'
  305. request_obj['Accept'] = 'application/json'
  306. payload = {
  307. jsonrpc: '2.0',
  308. method: 'session/handshake',
  309. params: {
  310. protocolVersion: '2025-03-26',
  311. clientInfo: {
  312. name: 'admin-test',
  313. version: '1.0.0'
  314. }
  315. },
  316. id: 1
  317. }
  318. request_obj.body = payload.to_json
  319. response = http.request(request_obj)
  320. if response.code == '200'
  321. response_data = JSON.parse(response.body)
  322. if response_data['jsonrpc'] == '2.0' && response_data['result']
  323. {
  324. success: true,
  325. details: {
  326. status_code: response.code,
  327. protocol_version: response_data['result']['protocolVersion'],
  328. capabilities: response_data['result']['capabilities'],
  329. server_info: response_data['result']['serverInfo']
  330. }
  331. }
  332. else
  333. {
  334. success: false,
  335. error: "Invalid response format: #{response_data}"
  336. }
  337. end
  338. else
  339. {
  340. success: false,
  341. error: "HTTP #{response.code}: #{response.body}"
  342. }
  343. end
  344. rescue => e
  345. {
  346. success: false,
  347. error: e.message
  348. }
  349. end
  350. end
  351. end

app/controllers/admin/media_controller.rb

0.0% lines covered

100.0% branches covered

109 relevant lines. 0 lines covered and 109 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::MediaController < Admin::BaseController
  2. before_action :set_medium, only: %i[ show edit update destroy ]
  3. # GET /admin/media or /admin/media.json
  4. def index
  5. @media = Medium.kept.includes(:user, :upload).order(created_at: :desc)
  6. # Show trashed if explicitly requested
  7. if params[:show_trash] == 'true'
  8. @media = Medium.trashed.includes(:user, :upload).order(deleted_at: :desc)
  9. end
  10. respond_to do |format|
  11. format.html do
  12. # For the new grid view, we just need the media objects
  13. # No need for complex JSON data structure
  14. end
  15. format.json { render json: media_json }
  16. end
  17. end
  18. # POST /admin/media/bulk_upload
  19. def bulk_upload
  20. uploaded_files = []
  21. errors = []
  22. if params[:media].present?
  23. params[:media].each do |index, media_params|
  24. medium = Medium.new(
  25. title: media_params[:title],
  26. user: current_user
  27. )
  28. if media_params[:file].present?
  29. medium.file.attach(media_params[:file])
  30. if medium.save
  31. uploaded_files << medium
  32. else
  33. errors << "#{media_params[:file].original_filename}: #{medium.errors.full_messages.join(', ')}"
  34. end
  35. end
  36. end
  37. end
  38. respond_to do |format|
  39. if errors.empty?
  40. format.json { render json: { success: true, message: "Successfully uploaded #{uploaded_files.count} file(s)" } }
  41. else
  42. format.json { render json: { success: false, message: errors.join('; ') }, status: :unprocessable_entity }
  43. end
  44. end
  45. end
  46. # GET /admin/media/1 or /admin/media/1.json
  47. def show
  48. end
  49. # GET /admin/media/new
  50. def new
  51. @medium = Medium.new
  52. end
  53. # GET /admin/media/1/edit
  54. def edit
  55. end
  56. # POST /admin/media or /admin/media.json
  57. def create
  58. @medium = Medium.new(medium_params)
  59. respond_to do |format|
  60. if @medium.save
  61. format.html { redirect_to [:admin, @medium], notice: "Medium was successfully created." }
  62. format.json { render :show, status: :created, location: @medium }
  63. else
  64. format.html { render :new, status: :unprocessable_entity }
  65. format.json { render json: @medium.errors, status: :unprocessable_entity }
  66. end
  67. end
  68. end
  69. # PATCH/PUT /admin/media/1 or /admin/media/1.json
  70. def update
  71. respond_to do |format|
  72. if @medium.update(medium_params)
  73. format.html { redirect_to [:admin, @medium], notice: "Medium was successfully updated.", status: :see_other }
  74. format.json { render :show, status: :ok, location: @medium }
  75. else
  76. format.html { render :edit, status: :unprocessable_entity }
  77. format.json { render json: @medium.errors, status: :unprocessable_entity }
  78. end
  79. end
  80. end
  81. # DELETE /admin/media/1 or /admin/media/1.json
  82. def destroy
  83. if @medium.trashed?
  84. @medium.destroy_permanently! # Permanent delete
  85. notice = "Media was permanently deleted."
  86. else
  87. @medium.trash!(current_user) # Soft delete
  88. notice = "Media was moved to trash."
  89. end
  90. respond_to do |format|
  91. format.html { redirect_to admin_media_path, notice: notice, status: :see_other }
  92. format.json { head :no_content }
  93. end
  94. end
  95. private
  96. # Use callbacks to share common setup or constraints between actions.
  97. def set_medium
  98. @medium = Medium.find(params[:id])
  99. end
  100. # Only allow a list of trusted parameters through.
  101. def medium_params
  102. params.fetch(:medium, {})
  103. end
  104. def media_json
  105. @media.includes(:upload).map do |medium|
  106. {
  107. id: medium.id,
  108. filename: medium.filename,
  109. title: medium.title,
  110. file_type: medium.content_type,
  111. file_size: medium.file_size,
  112. thumbnail_url: medium.image? ? medium.url : nil,
  113. quarantined: medium.quarantined?,
  114. quarantine_reason: medium.quarantine_reason,
  115. created_at: medium.created_at.iso8601,
  116. edit_url: edit_admin_medium_path(medium),
  117. show_url: admin_medium_path(medium),
  118. delete_url: admin_medium_path(medium)
  119. }
  120. end
  121. end
  122. end

app/controllers/admin/menus_controller.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::MenusController < Admin::BaseController
  2. before_action :set_menu, only: %i[ show edit update destroy ]
  3. # GET /admin/menus or /admin/menus.json
  4. def index
  5. @menus = Menu.all
  6. end
  7. # GET /admin/menus/1 or /admin/menus/1.json
  8. def show
  9. end
  10. # GET /admin/menus/new
  11. def new
  12. @menu = Menu.new
  13. end
  14. # GET /admin/menus/1/edit
  15. def edit
  16. end
  17. # POST /admin/menus or /admin/menus.json
  18. def create
  19. @menu = Menu.new(menu_params)
  20. respond_to do |format|
  21. if @menu.save
  22. format.html { redirect_to [:admin, @menu], notice: "Menu was successfully created." }
  23. format.json { render :show, status: :created, location: @menu }
  24. else
  25. format.html { render :new, status: :unprocessable_entity }
  26. format.json { render json: @menu.errors, status: :unprocessable_entity }
  27. end
  28. end
  29. end
  30. # PATCH/PUT /admin/menus/1 or /admin/menus/1.json
  31. def update
  32. respond_to do |format|
  33. if @menu.update(menu_params)
  34. format.html { redirect_to [:admin, @menu], notice: "Menu was successfully updated.", status: :see_other }
  35. format.json { render :show, status: :ok, location: @menu }
  36. else
  37. format.html { render :edit, status: :unprocessable_entity }
  38. format.json { render json: @menu.errors, status: :unprocessable_entity }
  39. end
  40. end
  41. end
  42. # DELETE /admin/menus/1 or /admin/menus/1.json
  43. def destroy
  44. @menu.destroy!
  45. respond_to do |format|
  46. format.html { redirect_to admin_menus_path, notice: "Menu was successfully destroyed.", status: :see_other }
  47. format.json { head :no_content }
  48. end
  49. end
  50. private
  51. # Use callbacks to share common setup or constraints between actions.
  52. def set_menu
  53. @menu = Menu.find(params[:id])
  54. end
  55. # Only allow a list of trusted parameters through.
  56. def menu_params
  57. params.fetch(:menu, {})
  58. end
  59. end

app/controllers/admin/oauth_controller.rb

0.0% lines covered

100.0% branches covered

307 relevant lines. 0 lines covered and 307 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::OauthController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/settings/oauth
  4. def index
  5. load_oauth_settings
  6. end
  7. # PATCH /admin/settings/oauth
  8. def update
  9. # Update OAuth settings
  10. if params[:settings]
  11. params[:settings].each do |key, value|
  12. SiteSetting.set(key, value, setting_type_for(key))
  13. end
  14. end
  15. # Update provider-specific settings
  16. update_provider_settings
  17. redirect_to admin_oauth_settings_path, notice: 'OAuth settings updated successfully.'
  18. end
  19. # POST /admin/settings/oauth/test_connection
  20. def test_connection
  21. provider = params[:provider]
  22. begin
  23. case provider
  24. when 'google'
  25. test_google_connection
  26. when 'github'
  27. test_github_connection
  28. when 'facebook'
  29. test_facebook_connection
  30. when 'twitter'
  31. test_twitter_connection
  32. else
  33. render json: { success: false, message: 'Unknown provider' }, status: :bad_request
  34. return
  35. end
  36. rescue => e
  37. render json: {
  38. success: false,
  39. message: "Connection test failed: #{e.message}"
  40. }, status: :unprocessable_entity
  41. end
  42. end
  43. private
  44. def load_oauth_settings
  45. @settings = {
  46. # Google OAuth
  47. google_enabled: SiteSetting.get('google_oauth_enabled', false),
  48. google_client_id: SiteSetting.get('google_oauth_client_id', ''),
  49. google_client_secret: SiteSetting.get('google_oauth_client_secret', ''),
  50. google_redirect_uri: SiteSetting.get('google_oauth_redirect_uri', ''),
  51. google_tenant: SiteSetting.get('google_oauth_tenant', ''),
  52. # GitHub OAuth
  53. github_enabled: SiteSetting.get('github_oauth_enabled', false),
  54. github_client_id: SiteSetting.get('github_oauth_client_id', ''),
  55. github_client_secret: SiteSetting.get('github_oauth_client_secret', ''),
  56. github_redirect_uri: SiteSetting.get('github_oauth_redirect_uri', ''),
  57. # Facebook OAuth
  58. facebook_enabled: SiteSetting.get('facebook_oauth_enabled', false),
  59. facebook_app_id: SiteSetting.get('facebook_oauth_app_id', ''),
  60. facebook_app_secret: SiteSetting.get('facebook_oauth_app_secret', ''),
  61. facebook_redirect_uri: SiteSetting.get('facebook_oauth_redirect_uri', ''),
  62. # Twitter OAuth
  63. twitter_enabled: SiteSetting.get('twitter_oauth_enabled', false),
  64. twitter_api_key: SiteSetting.get('twitter_oauth_api_key', ''),
  65. twitter_api_secret: SiteSetting.get('twitter_oauth_api_secret', ''),
  66. twitter_redirect_uri: SiteSetting.get('twitter_oauth_redirect_uri', ''),
  67. # General OAuth settings
  68. oauth_auto_register: SiteSetting.get('oauth_auto_register', true),
  69. oauth_default_role: SiteSetting.get('oauth_default_role', 'subscriber'),
  70. oauth_require_email: SiteSetting.get('oauth_require_email', true),
  71. oauth_allow_existing_users: SiteSetting.get('oauth_allow_existing_users', true)
  72. }
  73. end
  74. def update_provider_settings
  75. # Update Google OAuth settings
  76. if params[:google_client_id].present?
  77. SiteSetting.set('google_oauth_client_id', params[:google_client_id], 'string')
  78. end
  79. if params[:google_client_secret].present?
  80. SiteSetting.set('google_oauth_client_secret', params[:google_client_secret], 'string')
  81. end
  82. if params[:google_redirect_uri].present?
  83. SiteSetting.set('google_oauth_redirect_uri', params[:google_redirect_uri], 'string')
  84. end
  85. if params[:google_tenant].present?
  86. SiteSetting.set('google_oauth_tenant', params[:google_tenant], 'string')
  87. end
  88. # Update GitHub OAuth settings
  89. if params[:github_client_id].present?
  90. SiteSetting.set('github_oauth_client_id', params[:github_client_id], 'string')
  91. end
  92. if params[:github_client_secret].present?
  93. SiteSetting.set('github_oauth_client_secret', params[:github_client_secret], 'string')
  94. end
  95. if params[:github_redirect_uri].present?
  96. SiteSetting.set('github_oauth_redirect_uri', params[:github_redirect_uri], 'string')
  97. end
  98. # Update Facebook OAuth settings
  99. if params[:facebook_app_id].present?
  100. SiteSetting.set('facebook_oauth_app_id', params[:facebook_app_id], 'string')
  101. end
  102. if params[:facebook_app_secret].present?
  103. SiteSetting.set('facebook_oauth_app_secret', params[:facebook_app_secret], 'string')
  104. end
  105. if params[:facebook_redirect_uri].present?
  106. SiteSetting.set('facebook_oauth_redirect_uri', params[:facebook_redirect_uri], 'string')
  107. end
  108. # Update Twitter OAuth settings
  109. if params[:twitter_api_key].present?
  110. SiteSetting.set('twitter_oauth_api_key', params[:twitter_api_key], 'string')
  111. end
  112. if params[:twitter_api_secret].present?
  113. SiteSetting.set('twitter_oauth_api_secret', params[:twitter_api_secret], 'string')
  114. end
  115. if params[:twitter_redirect_uri].present?
  116. SiteSetting.set('twitter_oauth_redirect_uri', params[:twitter_redirect_uri], 'string')
  117. end
  118. end
  119. def setting_type_for(key)
  120. boolean_settings = %w[
  121. google_oauth_enabled github_oauth_enabled facebook_oauth_enabled twitter_oauth_enabled
  122. oauth_auto_register oauth_require_email oauth_allow_existing_users
  123. ]
  124. if boolean_settings.include?(key)
  125. 'boolean'
  126. else
  127. 'string'
  128. end
  129. end
  130. def test_google_connection
  131. client_id = SiteSetting.get('google_oauth_client_id', '')
  132. client_secret = SiteSetting.get('google_oauth_client_secret', '')
  133. if client_id.blank? || client_secret.blank?
  134. render json: { success: false, message: 'Google OAuth credentials not configured' }
  135. return
  136. end
  137. begin
  138. # Test Google OAuth by making a request to Google's token info endpoint
  139. # This validates that the client_id and client_secret are valid
  140. require 'net/http'
  141. require 'uri'
  142. # Create a test token request to validate credentials
  143. uri = URI('https://oauth2.googleapis.com/token')
  144. http = Net::HTTP.new(uri.host, uri.port)
  145. http.use_ssl = true
  146. request = Net::HTTP::Post.new(uri)
  147. request.set_form_data({
  148. 'client_id' => client_id,
  149. 'client_secret' => client_secret,
  150. 'grant_type' => 'authorization_code',
  151. 'code' => 'test_code', # This will fail but we can check the error type
  152. 'redirect_uri' => SiteSetting.get('google_oauth_redirect_uri', 'http://localhost:3000/auth/google/callback')
  153. })
  154. response = http.request(request)
  155. # If we get an "invalid_grant" error, it means the credentials are valid
  156. # but the authorization code is invalid (which is expected for a test)
  157. if response.code == '400'
  158. body = JSON.parse(response.body)
  159. if body['error'] == 'invalid_grant'
  160. render json: {
  161. success: true,
  162. message: 'Google OAuth credentials are valid'
  163. }
  164. else
  165. render json: {
  166. success: false,
  167. message: "Google OAuth error: #{body['error_description'] || body['error']}"
  168. }
  169. end
  170. else
  171. render json: {
  172. success: false,
  173. message: "Unexpected response from Google: #{response.code}"
  174. }
  175. end
  176. rescue => e
  177. render json: {
  178. success: false,
  179. message: "Google OAuth connection test failed: #{e.message}"
  180. }
  181. end
  182. end
  183. def test_github_connection
  184. client_id = SiteSetting.get('github_oauth_client_id', '')
  185. client_secret = SiteSetting.get('github_oauth_client_secret', '')
  186. if client_id.blank? || client_secret.blank?
  187. render json: { success: false, message: 'GitHub OAuth credentials not configured' }
  188. return
  189. end
  190. begin
  191. # Test GitHub OAuth by making a request to GitHub's API
  192. require 'net/http'
  193. require 'uri'
  194. # Create a test token request to validate credentials
  195. uri = URI('https://github.com/login/oauth/access_token')
  196. http = Net::HTTP.new(uri.host, uri.port)
  197. http.use_ssl = true
  198. request = Net::HTTP::Post.new(uri)
  199. request['Accept'] = 'application/json'
  200. request.set_form_data({
  201. 'client_id' => client_id,
  202. 'client_secret' => client_secret,
  203. 'code' => 'test_code' # This will fail but we can check the error type
  204. })
  205. response = http.request(request)
  206. # If we get an "incorrect_client_credentials" error, it means the credentials are invalid
  207. # If we get an "bad_verification_code" error, it means the credentials are valid
  208. if response.code == '200'
  209. body = JSON.parse(response.body)
  210. if body['error'] == 'bad_verification_code'
  211. render json: {
  212. success: true,
  213. message: 'GitHub OAuth credentials are valid'
  214. }
  215. else
  216. render json: {
  217. success: false,
  218. message: "GitHub OAuth error: #{body['error_description'] || body['error']}"
  219. }
  220. end
  221. else
  222. render json: {
  223. success: false,
  224. message: "Unexpected response from GitHub: #{response.code}"
  225. }
  226. end
  227. rescue => e
  228. render json: {
  229. success: false,
  230. message: "GitHub OAuth connection test failed: #{e.message}"
  231. }
  232. end
  233. end
  234. def test_facebook_connection
  235. app_id = SiteSetting.get('facebook_oauth_app_id', '')
  236. app_secret = SiteSetting.get('facebook_oauth_app_secret', '')
  237. if app_id.blank? || app_secret.blank?
  238. render json: { success: false, message: 'Facebook OAuth credentials not configured' }
  239. return
  240. end
  241. begin
  242. # Test Facebook OAuth by making a request to Facebook's Graph API
  243. require 'net/http'
  244. require 'uri'
  245. # Create a test app token request to validate credentials
  246. uri = URI("https://graph.facebook.com/oauth/access_token")
  247. http = Net::HTTP.new(uri.host, uri.port)
  248. http.use_ssl = true
  249. request = Net::HTTP::Get.new(uri)
  250. request.set_form_data({
  251. 'client_id' => app_id,
  252. 'client_secret' => app_secret,
  253. 'grant_type' => 'client_credentials'
  254. })
  255. response = http.request(request)
  256. if response.code == '200'
  257. body = JSON.parse(response.body)
  258. if body['access_token'].present?
  259. render json: {
  260. success: true,
  261. message: 'Facebook OAuth credentials are valid'
  262. }
  263. else
  264. render json: {
  265. success: false,
  266. message: 'Facebook OAuth credentials are invalid'
  267. }
  268. end
  269. else
  270. body = JSON.parse(response.body) rescue {}
  271. render json: {
  272. success: false,
  273. message: "Facebook OAuth error: #{body['error']&.dig('message') || 'Invalid credentials'}"
  274. }
  275. end
  276. rescue => e
  277. render json: {
  278. success: false,
  279. message: "Facebook OAuth connection test failed: #{e.message}"
  280. }
  281. end
  282. end
  283. def test_twitter_connection
  284. api_key = SiteSetting.get('twitter_oauth_api_key', '')
  285. api_secret = SiteSetting.get('twitter_oauth_api_secret', '')
  286. if api_key.blank? || api_secret.blank?
  287. render json: { success: false, message: 'Twitter OAuth credentials not configured' }
  288. return
  289. end
  290. begin
  291. # Test Twitter OAuth by making a request to Twitter's API
  292. require 'net/http'
  293. require 'uri'
  294. require 'base64'
  295. # Create a test bearer token request to validate credentials
  296. uri = URI('https://api.twitter.com/oauth2/token')
  297. http = Net::HTTP.new(uri.host, uri.port)
  298. http.use_ssl = true
  299. # Create basic auth header
  300. credentials = Base64.strict_encode64("#{api_key}:#{api_secret}")
  301. request = Net::HTTP::Post.new(uri)
  302. request['Authorization'] = "Basic #{credentials}"
  303. request['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
  304. request.set_form_data({
  305. 'grant_type' => 'client_credentials'
  306. })
  307. response = http.request(request)
  308. if response.code == '200'
  309. body = JSON.parse(response.body)
  310. if body['access_token'].present?
  311. render json: {
  312. success: true,
  313. message: 'Twitter OAuth credentials are valid'
  314. }
  315. else
  316. render json: {
  317. success: false,
  318. message: 'Twitter OAuth credentials are invalid'
  319. }
  320. end
  321. else
  322. body = JSON.parse(response.body) rescue {}
  323. render json: {
  324. success: false,
  325. message: "Twitter OAuth error: #{body['errors']&.first&.dig('message') || 'Invalid credentials'}"
  326. }
  327. end
  328. rescue => e
  329. render json: {
  330. success: false,
  331. message: "Twitter OAuth connection test failed: #{e.message}"
  332. }
  333. end
  334. end
  335. end

app/controllers/admin/page_templates_controller.rb

0.0% lines covered

100.0% branches covered

101 relevant lines. 0 lines covered and 101 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PageTemplatesController < Admin::BaseController
  2. before_action :set_page_template, only: %i[show edit update destroy toggle duplicate]
  3. layout :choose_layout
  4. # GET /admin/page_templates
  5. def index
  6. @page_templates = PageTemplate.ordered.includes(:pages)
  7. respond_to do |format|
  8. format.html
  9. format.json { render json: page_templates_json }
  10. end
  11. end
  12. # GET /admin/page_templates/1
  13. def show
  14. end
  15. # GET /admin/page_templates/new
  16. def new
  17. @page_template = PageTemplate.new(template_type: 'default')
  18. end
  19. # GET /admin/page_templates/1/edit
  20. def edit
  21. end
  22. # POST /admin/page_templates
  23. def create
  24. @page_template = PageTemplate.new(page_template_params)
  25. respond_to do |format|
  26. if @page_template.save
  27. format.html { redirect_to [:admin, @page_template], notice: "Page template was successfully created." }
  28. format.json { render :show, status: :created, location: @page_template }
  29. else
  30. format.html { render :new, status: :unprocessable_entity }
  31. format.json { render json: @page_template.errors, status: :unprocessable_entity }
  32. end
  33. end
  34. end
  35. # PATCH/PUT /admin/page_templates/1
  36. def update
  37. respond_to do |format|
  38. if @page_template.update(page_template_params)
  39. format.html { redirect_to [:admin, @page_template], notice: "Page template was successfully updated." }
  40. format.json { render :show, status: :ok, location: @page_template }
  41. else
  42. format.html { render :edit, status: :unprocessable_entity }
  43. format.json { render json: @page_template.errors, status: :unprocessable_entity }
  44. end
  45. end
  46. end
  47. # DELETE /admin/page_templates/1
  48. def destroy
  49. @page_template.destroy!
  50. respond_to do |format|
  51. format.html { redirect_to admin_page_templates_path, notice: "Page template was successfully destroyed." }
  52. format.json { head :no_content }
  53. end
  54. end
  55. # PATCH /admin/page_templates/1/toggle
  56. def toggle
  57. @page_template.update(active: !@page_template.active)
  58. respond_to do |format|
  59. format.json { render json: { success: true, active: @page_template.active } }
  60. end
  61. end
  62. # POST /admin/page_templates/1/duplicate
  63. def duplicate
  64. new_template = @page_template.dup
  65. new_template.name = "#{@page_template.name} (Copy)"
  66. new_template.position = PageTemplate.maximum(:position).to_i + 1
  67. if new_template.save
  68. redirect_to [:admin, new_template], notice: "Page template was successfully duplicated."
  69. else
  70. redirect_to admin_page_templates_path, alert: "Failed to duplicate page template."
  71. end
  72. end
  73. # GET /admin/page_templates/1/customize
  74. def customize
  75. @page_template = PageTemplate.find(params[:id])
  76. render layout: 'grapesjs_fullscreen'
  77. end
  78. # GET /admin/page_templates/1/theme_edit
  79. def theme_edit
  80. @page_template = PageTemplate.find(params[:id])
  81. @current_file_path = "page_templates/#{@page_template.id}/template.html"
  82. render layout: 'editor_fullscreen'
  83. end
  84. private
  85. def set_page_template
  86. @page_template = PageTemplate.find(params[:id])
  87. end
  88. def page_template_params
  89. params.require(:page_template).permit(
  90. :name, :template_type, :html_content, :css_content, :js_content,
  91. :active, :position
  92. )
  93. end
  94. def page_templates_json
  95. @page_templates.map do |template|
  96. {
  97. id: template.id,
  98. name: template.name,
  99. template_type: template.template_type,
  100. active: template.active,
  101. pages_count: template.pages.count,
  102. position: template.position,
  103. created_at: template.created_at.strftime("%Y-%m-%d %H:%M"),
  104. updated_at: template.updated_at.strftime("%Y-%m-%d %H:%M")
  105. }
  106. end
  107. end
  108. def choose_layout
  109. action_name == 'customize' ? 'grapesjs_fullscreen' :
  110. action_name == 'theme_edit' ? 'editor_fullscreen' : 'admin'
  111. end
  112. end

app/controllers/admin/pages_controller.rb

0.0% lines covered

100.0% branches covered

178 relevant lines. 0 lines covered and 178 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PagesController < Admin::BaseController
  2. before_action :set_page, only: %i[ show edit update destroy publish unpublish ]
  3. # GET /admin/pages or /admin/pages.json
  4. def index
  5. @pages = Page.kept
  6. # Filter by status if specified
  7. if params[:status].present? && Page.statuses.keys.include?(params[:status])
  8. @pages = @pages.where(status: params[:status])
  9. end
  10. # Show trashed if explicitly requested
  11. if params[:show_trash] == 'true'
  12. @pages = Page.trashed.includes(:user).order(deleted_at: :desc)
  13. else
  14. @pages = @pages.includes(:user).order(created_at: :desc)
  15. end
  16. respond_to do |format|
  17. format.html do
  18. @pages_data = pages_json
  19. @stats = {
  20. total: Page.kept.count,
  21. published: Page.published.count,
  22. draft: Page.where(status: 'draft').count,
  23. trash: Page.trashed.count
  24. }
  25. @bulk_actions = [
  26. { value: 'trash', label: 'Move to Trash' },
  27. { value: 'untrash', label: 'Restore' },
  28. { value: 'delete', label: 'Delete Permanently' }
  29. ]
  30. @status_options = [
  31. { value: 'published', label: 'Published' },
  32. { value: 'draft', label: 'Draft' },
  33. { value: 'pending', label: 'Pending' }
  34. ]
  35. @columns = [
  36. {
  37. title: "",
  38. formatter: "rowSelection",
  39. titleFormatter: "rowSelection",
  40. width: 40,
  41. headerSort: false
  42. },
  43. {
  44. title: "Title",
  45. field: "title",
  46. width: 300,
  47. formatter: "function(cell, formatterParams) { const data = cell.getRow().getData(); return '<a href=\"' + data.edit_url + '\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">' + data.title + '</a>'; }"
  48. },
  49. {
  50. title: "Author",
  51. field: "author_name",
  52. width: 150
  53. },
  54. {
  55. title: "Status",
  56. field: "status",
  57. width: 100,
  58. formatter: "function(cell, formatterParams) { const value = cell.getValue(); const statusMap = { 'published': { class: 'bg-green-100 text-green-800', label: 'Published' }, 'draft': { class: 'bg-yellow-100 text-yellow-800', label: 'Draft' }, 'pending': { class: 'bg-blue-100 text-blue-800', label: 'Pending' }, 'trash': { class: 'bg-red-100 text-red-800', label: 'Trash' } }; const status = statusMap[value] || { class: 'bg-gray-100 text-gray-800', label: value }; return '<span class=\"px-2 py-1 text-xs font-medium rounded-full ' + status.class + '\">' + status.label + '</span>'; }"
  59. },
  60. {
  61. title: "Template",
  62. field: "template",
  63. width: 120,
  64. formatter: "function(cell, formatterParams) { const template = cell.getValue(); return template || '<span class=\"text-gray-400\">Default</span>'; }"
  65. },
  66. {
  67. title: "Date",
  68. field: "created_at",
  69. width: 150,
  70. formatter: "function(cell, formatterParams) { const date = new Date(cell.getValue()); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); }"
  71. },
  72. {
  73. title: "Actions",
  74. field: "actions",
  75. width: 120,
  76. headerSort: false,
  77. formatter: "function(cell, formatterParams) { const data = cell.getRow().getData(); let actions = ''; if (data.edit_url) { actions += '<a href=\"' + data.edit_url + '\" class=\"text-indigo-600 hover:text-indigo-900 mr-2\" title=\"Edit\">✏️</a>'; } if (data.show_url) { actions += '<a href=\"' + data.show_url + '\" class=\"text-blue-600 hover:text-blue-900 mr-2\" title=\"View\">👁️</a>'; } if (data.delete_url) { actions += '<a href=\"' + data.delete_url + '\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\" data-confirm=\"Are you sure?\">🗑️</a>'; } return actions; }"
  78. }
  79. ]
  80. end
  81. format.json { render json: pages_json }
  82. end
  83. end
  84. # GET /admin/pages/1 or /admin/pages/1.json
  85. def show
  86. end
  87. # GET /admin/pages/new
  88. def new
  89. @page = current_user.pages.build(status: :draft)
  90. end
  91. # GET /admin/pages/1/edit
  92. def edit
  93. end
  94. # POST /admin/pages or /admin/pages.json
  95. def create
  96. @page = current_user.pages.build(page_params)
  97. respond_to do |format|
  98. if @page.save
  99. format.html { redirect_to [:admin, @page], notice: "Page was successfully created." }
  100. format.json { render :show, status: :created, location: [:admin, @page] }
  101. else
  102. format.html { render :new, status: :unprocessable_entity }
  103. format.json { render json: @page.errors, status: :unprocessable_entity }
  104. end
  105. end
  106. end
  107. # PATCH/PUT /admin/pages/1 or /admin/pages/1.json
  108. def update
  109. respond_to do |format|
  110. if @page.update(page_params)
  111. format.html { redirect_to [:admin, @page], notice: "Page was successfully updated." }
  112. format.json { render :show, status: :ok, location: [:admin, @page] }
  113. else
  114. format.html { render :edit, status: :unprocessable_entity }
  115. format.json { render json: @page.errors, status: :unprocessable_entity }
  116. end
  117. end
  118. end
  119. # DELETE /admin/pages/1 or /admin/pages/1.json
  120. def destroy
  121. if @page.trashed?
  122. @page.destroy_permanently! # Permanent delete
  123. notice = "Page was permanently deleted."
  124. else
  125. @page.trash!(current_user) # Soft delete
  126. notice = "Page was moved to trash."
  127. end
  128. respond_to do |format|
  129. format.html { redirect_to admin_pages_path, notice: notice, status: :see_other }
  130. format.json { head :no_content }
  131. end
  132. end
  133. # PATCH /admin/pages/1/publish
  134. def publish
  135. @page.update(status: :published, published_at: Time.current)
  136. redirect_to admin_pages_path, notice: "Page was successfully published."
  137. end
  138. # PATCH /admin/pages/1/unpublish
  139. def unpublish
  140. @page.update(status: :draft)
  141. redirect_to admin_pages_path, notice: "Page was unpublished."
  142. end
  143. # POST /admin/pages/bulk_action
  144. def bulk_action
  145. action_type = params[:action_type]
  146. page_ids = params[:page_ids] || []
  147. pages = Page.where(id: page_ids)
  148. case action_type
  149. when 'publish'
  150. pages.update_all(status: :published, published_at: Time.current)
  151. message = "#{pages.count} pages published"
  152. when 'unpublish'
  153. pages.update_all(status: :draft)
  154. message = "#{pages.count} pages unpublished"
  155. when 'delete'
  156. pages.destroy_all
  157. message = "#{pages.count} pages deleted"
  158. else
  159. message = "Invalid action"
  160. end
  161. respond_to do |format|
  162. format.json { render json: { success: true, message: message } }
  163. end
  164. end
  165. private
  166. def set_page
  167. @page = Page.friendly.find(params[:id])
  168. end
  169. def page_params
  170. params.require(:page).permit(
  171. :title, :slug, :content, :status, :published_at,
  172. :parent_id, :order, :template, :meta_description, :meta_keywords,
  173. :password, :password_hint
  174. )
  175. end
  176. def pages_json
  177. @pages.map do |page|
  178. {
  179. id: page.id,
  180. title: page.title,
  181. slug: page.slug,
  182. status: page.status,
  183. author: page.user.email.split('@').first,
  184. created_at: page.created_at.strftime("%Y-%m-%d %H:%M"),
  185. published_at: page.published_at&.strftime("%Y-%m-%d %H:%M"),
  186. actions: view_context.link_to('Edit', edit_admin_page_path(page), class: 'text-indigo-400 hover:text-indigo-300')
  187. }
  188. end
  189. end
  190. end

app/controllers/admin/passwords_controller.rb

0.0% lines covered

100.0% branches covered

7 relevant lines. 0 lines covered and 7 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PasswordsController < Devise::PasswordsController
  2. layout 'admin_login'
  3. helper AppearanceHelper
  4. # Override to redirect to admin login after password reset
  5. def after_resetting_password_path_for(resource)
  6. new_admin_user_session_path
  7. end
  8. end

app/controllers/admin/pixels_controller.rb

0.0% lines covered

100.0% branches covered

82 relevant lines. 0 lines covered and 82 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PixelsController < Admin::BaseController
  2. before_action :set_pixel, only: [:edit, :update, :destroy, :toggle, :test]
  3. # GET /admin/pixels
  4. def index
  5. @pixels = Pixel.includes(:versions).ordered
  6. # Filter by status
  7. @pixels = @pixels.active if params[:status] == 'active'
  8. @pixels = @pixels.inactive if params[:status] == 'inactive'
  9. # Filter by provider
  10. @pixels = @pixels.by_provider(params[:provider]) if params[:provider].present?
  11. # Filter by position
  12. @pixels = @pixels.by_position(params[:position]) if params[:position].present?
  13. # Stats
  14. @stats = {
  15. total: Pixel.count,
  16. active: Pixel.active.count,
  17. inactive: Pixel.inactive.count,
  18. providers: Pixel.group(:provider).count.keys.compact.count
  19. }
  20. end
  21. # GET /admin/pixels/new
  22. def new
  23. @pixel = Pixel.new
  24. end
  25. # GET /admin/pixels/:id/edit
  26. def edit
  27. end
  28. # POST /admin/pixels
  29. def create
  30. @pixel = Pixel.new(pixel_params)
  31. if @pixel.save
  32. redirect_to admin_pixels_path, notice: 'Pixel added successfully.'
  33. else
  34. render :new, status: :unprocessable_entity
  35. end
  36. end
  37. # PATCH/PUT /admin/pixels/:id
  38. def update
  39. if @pixel.update(pixel_params)
  40. redirect_to admin_pixels_path, notice: 'Pixel updated successfully.'
  41. else
  42. render :edit, status: :unprocessable_entity
  43. end
  44. end
  45. # DELETE /admin/pixels/:id
  46. def destroy
  47. @pixel.destroy
  48. redirect_to admin_pixels_path, notice: 'Pixel deleted successfully.'
  49. end
  50. # PATCH /admin/pixels/:id/toggle
  51. def toggle
  52. @pixel.update(active: !@pixel.active)
  53. redirect_to admin_pixels_path, notice: "Pixel #{@pixel.active? ? 'activated' : 'deactivated'}."
  54. end
  55. # GET /admin/pixels/:id/test
  56. def test
  57. @pixel_code = @pixel.render_code
  58. render layout: false
  59. end
  60. # POST /admin/pixels/bulk_action
  61. def bulk_action
  62. pixel_ids = params[:pixel_ids] || []
  63. action = params[:bulk_action]
  64. case action
  65. when 'activate'
  66. Pixel.where(id: pixel_ids).update_all(active: true)
  67. message = "#{pixel_ids.count} pixels activated."
  68. when 'deactivate'
  69. Pixel.where(id: pixel_ids).update_all(active: false)
  70. message = "#{pixel_ids.count} pixels deactivated."
  71. when 'delete'
  72. Pixel.where(id: pixel_ids).destroy_all
  73. message = "#{pixel_ids.count} pixels deleted."
  74. else
  75. message = "Invalid action."
  76. end
  77. redirect_to admin_pixels_path, notice: message
  78. end
  79. private
  80. def set_pixel
  81. @pixel = Pixel.find(params[:id])
  82. end
  83. def pixel_params
  84. params.require(:pixel).permit(
  85. :name,
  86. :pixel_type,
  87. :provider,
  88. :pixel_id,
  89. :custom_code,
  90. :position,
  91. :active,
  92. :notes
  93. )
  94. end
  95. end

app/controllers/admin/plugin_pages_controller.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PluginPagesController < Admin::BaseController
  2. before_action :set_plugin
  3. before_action :check_capability
  4. # Show plugin admin page
  5. def show
  6. @page_slug = params[:page_slug]
  7. @admin_page = @plugin.admin_pages.find { |p| p[:slug] == @page_slug }
  8. unless @admin_page
  9. redirect_to admin_plugins_path, alert: "Page not found"
  10. return
  11. end
  12. # If the plugin has a custom callback for this page
  13. if @admin_page[:callback]
  14. @page_data = @plugin.send(@admin_page[:callback])
  15. else
  16. # Default: render settings page
  17. @page_data = @plugin.render_settings_page
  18. end
  19. end
  20. # Update plugin settings
  21. def update
  22. @page_slug = params[:page_slug]
  23. # Update all submitted settings
  24. if params[:settings]
  25. params[:settings].each do |key, value|
  26. @plugin.set_setting(key.to_sym, value)
  27. end
  28. flash[:notice] = "Settings saved successfully"
  29. end
  30. redirect_to admin_plugin_page_path(plugin_identifier: params[:plugin_identifier], page_slug: @page_slug)
  31. end
  32. # Handle custom actions
  33. def action
  34. action_name = params[:action_name]
  35. if @plugin.respond_to?(action_name)
  36. result = @plugin.send(action_name, params)
  37. render json: { success: true, data: result }
  38. else
  39. render json: { success: false, error: "Action not found" }, status: 404
  40. end
  41. end
  42. private
  43. def set_plugin
  44. plugin_identifier = params[:plugin_identifier]
  45. @plugin = Railspress::PluginSystem.get_plugin(plugin_identifier)
  46. unless @plugin
  47. redirect_to admin_plugins_path, alert: "Plugin not found"
  48. end
  49. end
  50. def check_capability
  51. # Check if user has required capability for this page
  52. return if current_user.administrator?
  53. capability = @plugin.admin_pages.find { |p| p[:slug] == params[:page_slug] }&.dig(:capability)
  54. case capability
  55. when 'editor'
  56. ensure_editor_access
  57. when 'author'
  58. # Authors have basic access
  59. else
  60. ensure_admin
  61. end
  62. end
  63. end

app/controllers/admin/plugins_controller.rb

0.0% lines covered

100.0% branches covered

491 relevant lines. 0 lines covered and 491 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PluginsController < Admin::BaseController
  2. include PluginSettingsHelper
  3. before_action :ensure_admin
  4. before_action :set_plugin, only: [:show, :edit, :update, :destroy, :activate, :deactivate, :settings]
  5. # GET /admin/plugins
  6. def index
  7. # Auto-discover and register plugins from filesystem
  8. discover_and_register_plugins
  9. @installed_plugins = Plugin.all.order(active: :desc, name: :asc)
  10. end
  11. # GET /admin/plugins/browse
  12. def browse
  13. @available_plugins = fetch_available_plugins
  14. @categories = plugin_categories
  15. @featured_plugins = @available_plugins.select { |p| p[:featured] }
  16. # Filter by category
  17. if params[:category].present?
  18. @available_plugins = @available_plugins.select { |p| p[:category] == params[:category] }
  19. end
  20. # Search
  21. if params[:q].present?
  22. query = params[:q].downcase
  23. @available_plugins = @available_plugins.select do |p|
  24. p[:name].downcase.include?(query) ||
  25. p[:description].downcase.include?(query) ||
  26. p[:tags].any? { |t| t.downcase.include?(query) }
  27. end
  28. end
  29. end
  30. # GET /admin/plugins/marketplace
  31. def marketplace
  32. @available_plugins = fetch_available_plugins
  33. @categories = plugin_categories
  34. @featured_plugins = @available_plugins.select { |p| p[:featured] }
  35. @popular_plugins = @available_plugins.sort_by { |p| -p[:downloads] }.first(10)
  36. @new_plugins = @available_plugins.select { |p| p[:new] }.first(10)
  37. # Filter by category
  38. if params[:category].present?
  39. @available_plugins = @available_plugins.select { |p| p[:category] == params[:category] }
  40. end
  41. # Search
  42. if params[:q].present?
  43. query = params[:q].downcase
  44. @available_plugins = @available_plugins.select do |p|
  45. p[:name].downcase.include?(query) ||
  46. p[:description].downcase.include?(query) ||
  47. p[:tags].any? { |t| t.downcase.include?(query) }
  48. end
  49. end
  50. end
  51. # GET /admin/plugins/1
  52. def show
  53. end
  54. # GET /admin/plugins/new
  55. def new
  56. @plugin = Plugin.new
  57. end
  58. # GET /admin/plugins/1/edit
  59. def edit
  60. end
  61. # POST /admin/plugins
  62. def create
  63. @plugin = Plugin.new(plugin_params)
  64. if @plugin.save
  65. redirect_to admin_plugins_path, notice: "Plugin was successfully created."
  66. else
  67. render :new, status: :unprocessable_entity
  68. end
  69. end
  70. # PATCH/PUT /admin/plugins/1
  71. def update
  72. if @plugin.update(plugin_params)
  73. redirect_to admin_plugins_path, notice: "Plugin was successfully updated."
  74. else
  75. render :edit, status: :unprocessable_entity
  76. end
  77. end
  78. # DELETE /admin/plugins/1
  79. def destroy
  80. @plugin.destroy
  81. redirect_to admin_plugins_path, notice: "Plugin was successfully deleted."
  82. end
  83. # PATCH /admin/plugins/1/activate
  84. def activate
  85. if @plugin.activate!
  86. # Load the plugin
  87. load_plugin(@plugin)
  88. redirect_to admin_plugins_path, notice: "Plugin '#{@plugin.name}' activated successfully."
  89. else
  90. redirect_to admin_plugins_path, alert: "Failed to activate plugin."
  91. end
  92. end
  93. # PATCH /admin/plugins/1/deactivate
  94. def deactivate
  95. if @plugin.deactivate!
  96. redirect_to admin_plugins_path, notice: "Plugin '#{@plugin.name}' deactivated."
  97. else
  98. redirect_to admin_plugins_path, alert: "Failed to deactivate plugin."
  99. end
  100. end
  101. # POST /admin/plugins/install
  102. def install
  103. plugin_slug = params[:plugin_slug]
  104. plugin_data = fetch_available_plugins.find { |p| p[:slug] == plugin_slug }
  105. unless plugin_data
  106. return redirect_to browse_admin_plugins_path, alert: "Plugin not found."
  107. end
  108. # Check if already installed
  109. if Plugin.exists?(name: plugin_data[:name])
  110. return redirect_to browse_admin_plugins_path, alert: "Plugin already installed."
  111. end
  112. # Create plugin record
  113. plugin = Plugin.create!(
  114. name: plugin_data[:name],
  115. description: plugin_data[:description],
  116. author: plugin_data[:author],
  117. version: plugin_data[:version],
  118. active: false
  119. )
  120. # In a real implementation, this would download and install the plugin files
  121. # For now, we just create the database record
  122. redirect_to admin_plugins_path, notice: "Plugin '#{plugin.name}' installed successfully. You can now activate it."
  123. end
  124. # GET /admin/plugins/1/settings
  125. def settings
  126. # Try to load plugin instance to get schema and defaults
  127. @plugin_instance = load_plugin_instance(@plugin)
  128. if @plugin_instance&.has_settings?
  129. # Get saved settings from PluginSetting model
  130. saved_settings = @plugin_instance.get_all_settings
  131. # Get default values from schema
  132. default_settings = {}
  133. @plugin_instance.settings_schema.each do |setting|
  134. default_settings[setting[:key]] = setting[:default] if setting[:default]
  135. end
  136. # Merge defaults with saved settings (saved settings take precedence)
  137. @plugin_settings = default_settings.merge(saved_settings)
  138. @schema = @plugin_instance.settings_schema
  139. else
  140. # Fallback: get settings from plugin's settings attribute
  141. saved_settings = @plugin.settings || {}
  142. @plugin_settings = saved_settings
  143. @schema = nil
  144. end
  145. end
  146. # PATCH /admin/plugins/1/update_settings
  147. def update_settings
  148. @plugin = Plugin.find(params[:id])
  149. new_settings = settings_params
  150. # Try to get plugin instance for validation
  151. @plugin_instance = load_plugin_instance(@plugin)
  152. if @plugin_instance&.has_settings?
  153. # Save settings using the plugin's settings system
  154. begin
  155. new_settings.each do |key, value|
  156. @plugin_instance.set_setting(key, value)
  157. end
  158. redirect_to settings_admin_plugin_path(@plugin), notice: "Plugin settings updated successfully."
  159. rescue => e
  160. Rails.logger.error "Failed to update plugin settings: #{e.message}"
  161. redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings: #{e.message}"
  162. end
  163. else
  164. # Fallback: save to plugin's settings attribute for plugins without schema
  165. if @plugin.update(settings: new_settings)
  166. redirect_to settings_admin_plugin_path(@plugin), notice: "Plugin settings updated successfully."
  167. else
  168. redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings."
  169. end
  170. end
  171. end
  172. private
  173. def set_plugin
  174. @plugin = Plugin.find(params[:id])
  175. end
  176. def plugin_params
  177. params.require(:plugin).permit(:name, :description, :author, :version, :active, :settings)
  178. end
  179. def load_plugin(plugin)
  180. plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
  181. if File.exist?(plugin_path)
  182. require plugin_path
  183. Rails.logger.info "Loaded plugin: #{plugin.name}"
  184. end
  185. end
  186. def plugin_categories
  187. [
  188. 'SEO & Marketing',
  189. 'Security',
  190. 'Performance',
  191. 'Social Media',
  192. 'Analytics',
  193. 'Content Enhancement',
  194. 'E-commerce',
  195. 'Forms & Contact',
  196. 'Media & Gallery',
  197. 'Development Tools'
  198. ]
  199. end
  200. # Mock plugin marketplace - In production, this would fetch from a real API
  201. def fetch_available_plugins
  202. [
  203. {
  204. slug: 'seo-optimizer',
  205. name: 'SEO Optimizer Pro',
  206. author: 'RailsPress Team',
  207. version: '2.5.0',
  208. description: 'Complete SEO solution with XML sitemaps, meta tag management, social media integration, and Google Analytics.',
  209. long_description: 'SEO Optimizer Pro is the most comprehensive SEO plugin for RailsPress. It includes automatic XML sitemaps, advanced meta tag management, Open Graph and Twitter Card support, breadcrumbs, canonical URLs, and Google Analytics integration. Perfect for improving your search engine rankings.',
  210. rating: 4.8,
  211. downloads: 125000,
  212. category: 'SEO & Marketing',
  213. tags: ['seo', 'google', 'analytics', 'sitemap', 'meta tags'],
  214. featured: true,
  215. screenshots: ['screenshot1.png', 'screenshot2.png'],
  216. requires: '1.0.0',
  217. tested_up_to: '1.5.0',
  218. last_updated: '2025-09-15'
  219. },
  220. {
  221. slug: 'contact-form-builder',
  222. name: 'Contact Form Builder',
  223. author: 'FormCraft',
  224. version: '1.8.2',
  225. description: 'Drag-and-drop form builder with email notifications, spam protection, and integrations.',
  226. long_description: 'Build beautiful contact forms with our intuitive drag-and-drop interface. Includes reCAPTCHA integration, email notifications, file uploads, conditional logic, multi-page forms, and integrations with popular email marketing services.',
  227. rating: 4.9,
  228. downloads: 89000,
  229. category: 'Forms & Contact',
  230. tags: ['forms', 'contact', 'email', 'captcha'],
  231. featured: true,
  232. screenshots: [],
  233. requires: '1.0.0',
  234. tested_up_to: '1.5.0',
  235. last_updated: '2025-10-01'
  236. },
  237. {
  238. slug: 'security-guardian',
  239. name: 'Security Guardian',
  240. author: 'SecureRails',
  241. version: '3.1.0',
  242. description: 'Advanced security features including firewall, malware scanning, and two-factor authentication.',
  243. long_description: 'Protect your site with enterprise-grade security. Features include web application firewall, malware scanning, brute force protection, two-factor authentication, security headers, file integrity monitoring, and detailed security reports.',
  244. rating: 4.7,
  245. downloads: 67000,
  246. category: 'Security',
  247. tags: ['security', '2fa', 'firewall', 'malware'],
  248. featured: false,
  249. screenshots: [],
  250. requires: '1.0.0',
  251. tested_up_to: '1.5.0',
  252. last_updated: '2025-09-28'
  253. },
  254. {
  255. slug: 'performance-booster',
  256. name: 'Performance Booster',
  257. author: 'SpeedUp Labs',
  258. version: '2.0.3',
  259. description: 'Comprehensive caching, minification, lazy loading, and CDN integration for maximum performance.',
  260. long_description: 'Supercharge your site speed with advanced caching strategies, asset minification, image optimization, lazy loading, database query optimization, and CDN integration. Includes performance monitoring and detailed reports.',
  261. rating: 4.6,
  262. downloads: 54000,
  263. category: 'Performance',
  264. tags: ['cache', 'speed', 'optimization', 'cdn'],
  265. featured: true,
  266. screenshots: [],
  267. requires: '1.0.0',
  268. tested_up_to: '1.5.0',
  269. last_updated: '2025-10-05'
  270. },
  271. {
  272. slug: 'social-share-buttons',
  273. name: 'Social Share Buttons',
  274. author: 'ShareKit',
  275. version: '1.5.1',
  276. description: 'Beautiful, customizable social media sharing buttons for all major platforms.',
  277. long_description: 'Add stunning social sharing buttons to your posts and pages. Supports Facebook, Twitter, LinkedIn, Pinterest, Reddit, WhatsApp, and more. Fully customizable with multiple styles, floating sidebar option, and share count display.',
  278. rating: 4.5,
  279. downloads: 98000,
  280. category: 'Social Media',
  281. tags: ['social', 'sharing', 'facebook', 'twitter'],
  282. featured: false,
  283. screenshots: [],
  284. requires: '1.0.0',
  285. tested_up_to: '1.5.0',
  286. last_updated: '2025-08-20'
  287. },
  288. {
  289. slug: 'analytics-dashboard',
  290. name: 'Analytics Dashboard Pro',
  291. author: 'DataViz Inc',
  292. version: '4.2.0',
  293. description: 'Real-time analytics dashboard with visitor tracking, heatmaps, and detailed reports.',
  294. long_description: 'Get deep insights into your site traffic with real-time analytics. Track visitors, page views, bounce rates, conversion goals, heatmaps, user flow, geographic data, and more. Beautiful charts and exportable reports included.',
  295. rating: 4.9,
  296. downloads: 43000,
  297. category: 'Analytics',
  298. tags: ['analytics', 'tracking', 'reports', 'stats'],
  299. featured: false,
  300. screenshots: [],
  301. requires: '1.2.0',
  302. tested_up_to: '1.5.0',
  303. last_updated: '2025-10-10'
  304. },
  305. {
  306. slug: 'markdown-editor',
  307. name: 'Markdown Editor Plus',
  308. author: 'EditorTech',
  309. version: '1.3.0',
  310. description: 'Enhanced markdown editor with live preview, syntax highlighting, and export options.',
  311. long_description: 'Write content in markdown with our enhanced editor. Features include live preview, syntax highlighting, table support, footnotes, emoji picker, export to multiple formats, and seamless integration with the existing rich text editor.',
  312. rating: 4.4,
  313. downloads: 31000,
  314. category: 'Content Enhancement',
  315. tags: ['markdown', 'editor', 'writing'],
  316. featured: false,
  317. screenshots: [],
  318. requires: '1.0.0',
  319. tested_up_to: '1.5.0',
  320. last_updated: '2025-09-12'
  321. },
  322. {
  323. slug: 'image-gallery',
  324. name: 'Advanced Image Gallery',
  325. author: 'GalleryPro',
  326. version: '2.1.5',
  327. description: 'Create stunning image galleries with lightbox, masonry layouts, and slideshow features.',
  328. long_description: 'Build beautiful image galleries with multiple layout options including masonry, grid, carousel, and justified layouts. Features lightbox viewer, slideshow mode, lazy loading, touch gestures, captions, and social sharing.',
  329. rating: 4.7,
  330. downloads: 72000,
  331. category: 'Media & Gallery',
  332. tags: ['gallery', 'images', 'lightbox', 'slideshow'],
  333. featured: false,
  334. screenshots: [],
  335. requires: '1.0.0',
  336. tested_up_to: '1.5.0',
  337. last_updated: '2025-09-30'
  338. },
  339. {
  340. slug: 'ecommerce-lite',
  341. name: 'E-Commerce Lite',
  342. author: 'ShopRails',
  343. version: '3.0.0',
  344. description: 'Lightweight e-commerce solution with product management, cart, and payment integration.',
  345. long_description: 'Transform your site into an online store with our lightweight e-commerce plugin. Features include product catalog, shopping cart, checkout process, Stripe integration, inventory management, order tracking, and customer accounts.',
  346. rating: 4.6,
  347. downloads: 38000,
  348. category: 'E-commerce',
  349. tags: ['shop', 'ecommerce', 'products', 'payments'],
  350. featured: true,
  351. screenshots: [],
  352. requires: '1.2.0',
  353. tested_up_to: '1.5.0',
  354. last_updated: '2025-10-08'
  355. },
  356. {
  357. slug: 'backup-manager',
  358. name: 'Backup Manager',
  359. author: 'BackupSafe',
  360. version: '1.6.0',
  361. description: 'Automated backups with cloud storage support and one-click restore functionality.',
  362. long_description: 'Never lose your data with automated backups to cloud storage. Supports AWS S3, Google Cloud Storage, Dropbox, and more. Schedule automatic backups, one-click restore, incremental backups, and email notifications.',
  363. rating: 4.8,
  364. downloads: 56000,
  365. category: 'Development Tools',
  366. tags: ['backup', 'restore', 'cloud', 's3'],
  367. featured: false,
  368. screenshots: [],
  369. requires: '1.0.0',
  370. tested_up_to: '1.5.0',
  371. last_updated: '2025-09-25'
  372. },
  373. {
  374. slug: 'multilingual',
  375. name: 'Multilingual Content Manager',
  376. author: 'TranslateCMS',
  377. version: '2.3.1',
  378. description: 'Complete multilingual solution with automatic translation and language switcher.',
  379. long_description: 'Make your site multilingual with ease. Features include manual and automatic translation, language switcher widget, SEO for multiple languages, RTL support, translation management interface, and integration with Google Translate API.',
  380. rating: 4.5,
  381. downloads: 29000,
  382. category: 'Content Enhancement',
  383. tags: ['translation', 'multilingual', 'i18n', 'languages'],
  384. featured: false,
  385. screenshots: [],
  386. requires: '1.3.0',
  387. tested_up_to: '1.5.0',
  388. last_updated: '2025-10-02'
  389. },
  390. {
  391. slug: 'email-marketing',
  392. name: 'Email Marketing Suite',
  393. author: 'MailRails',
  394. version: '1.9.0',
  395. description: 'Newsletter management, email campaigns, subscriber management, and analytics.',
  396. long_description: 'Build your email list and send beautiful newsletters. Features include subscriber management, email campaign builder, drag-and-drop email designer, automation, segmentation, A/B testing, and detailed analytics.',
  397. rating: 4.7,
  398. downloads: 41000,
  399. category: 'SEO & Marketing',
  400. tags: ['email', 'newsletter', 'marketing', 'campaigns'],
  401. featured: false,
  402. screenshots: [],
  403. requires: '1.1.0',
  404. tested_up_to: '1.5.0',
  405. last_updated: '2025-09-18'
  406. }
  407. ]
  408. end
  409. # GET /admin/plugins/1/settings
  410. # PATCH /admin/plugins/1/update_settings
  411. def update_settings
  412. new_settings = settings_params
  413. # Try to get schema for validation
  414. @plugin_instance = Railspress::PluginSystem.get_plugin(@plugin.name.underscore) rescue nil
  415. if @plugin_instance && @plugin_instance.settings_schema
  416. # Validate against schema
  417. errors = @plugin_instance.settings_schema.validate(new_settings)
  418. if errors.any?
  419. flash[:alert] = "Validation errors: #{errors.values.flatten.join(', ')}"
  420. redirect_to settings_admin_plugin_path(@plugin)
  421. return
  422. end
  423. end
  424. if @plugin.update(settings: new_settings)
  425. redirect_to admin_plugins_path, notice: "Plugin settings updated successfully."
  426. else
  427. redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings."
  428. end
  429. end
  430. private
  431. def set_plugin
  432. @plugin = Plugin.find(params[:id])
  433. end
  434. def plugin_params
  435. params.require(:plugin).permit(:name, :description, :author, :version, :active)
  436. end
  437. def settings_params
  438. # Handle both nested and flat settings parameters
  439. if params[:plugin] && params[:plugin][:settings]
  440. params.require(:plugin).permit(:settings).to_h.dig(:settings) || {}
  441. elsif params[:settings]
  442. params.permit(settings: {}).to_h[:settings] || {}
  443. else
  444. {}
  445. end
  446. end
  447. def load_plugin(plugin)
  448. plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
  449. if File.exist?(plugin_path)
  450. begin
  451. load plugin_path
  452. Rails.logger.info "Successfully loaded plugin: #{plugin.name}"
  453. true
  454. rescue => e
  455. Rails.logger.error "Failed to load plugin #{plugin.name}: #{e.message}"
  456. false
  457. end
  458. else
  459. Rails.logger.warn "Plugin file not found: #{plugin_path}"
  460. true # Don't fail if file doesn't exist yet
  461. end
  462. end
  463. def discover_and_register_plugins
  464. plugins_dir = Rails.root.join('lib', 'plugins')
  465. return unless Dir.exist?(plugins_dir)
  466. # Scan for plugin directories
  467. Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
  468. next unless File.directory?(plugin_dir)
  469. plugin_name = File.basename(plugin_dir)
  470. plugin_file = File.join(plugin_dir, "#{plugin_name}.rb")
  471. next unless File.exist?(plugin_file)
  472. # Check if plugin is already registered
  473. next if Plugin.exists?(name: plugin_name.humanize)
  474. begin
  475. # Load the plugin file to get metadata
  476. load plugin_file
  477. # Try to get plugin class and metadata
  478. plugin_class_name = plugin_name.classify
  479. plugin_class = plugin_class_name.constantize rescue nil
  480. if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
  481. # Create plugin instance to get metadata
  482. plugin_instance = plugin_class.new
  483. # Get metadata from instance
  484. plugin_name_str = plugin_instance.name
  485. plugin_version_str = plugin_instance.version
  486. plugin_description_str = plugin_instance.description
  487. plugin_author_str = plugin_instance.author || 'Unknown'
  488. # Register in database
  489. Plugin.create!(
  490. name: plugin_name_str,
  491. description: plugin_description_str,
  492. author: plugin_author_str,
  493. version: plugin_version_str,
  494. active: false
  495. )
  496. Rails.logger.info "Auto-registered plugin: #{plugin_name_str}"
  497. else
  498. # If plugin class doesn't inherit from PluginBase, try to register with basic info
  499. Plugin.create!(
  500. name: plugin_name.humanize,
  501. description: "Plugin: #{plugin_name.humanize}",
  502. author: 'Unknown',
  503. version: '1.0.0',
  504. active: false
  505. )
  506. Rails.logger.info "Auto-registered basic plugin: #{plugin_name.humanize}"
  507. end
  508. rescue => e
  509. Rails.logger.error "Failed to auto-register plugin #{plugin_name}: #{e.message}"
  510. end
  511. end
  512. end
  513. def load_plugin_instance(plugin)
  514. # Dynamic plugin discovery - find the plugin directory that matches this plugin
  515. plugins_dir = Rails.root.join('lib', 'plugins')
  516. return nil unless Dir.exist?(plugins_dir)
  517. Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
  518. next unless File.directory?(plugin_dir)
  519. candidate_name = File.basename(plugin_dir)
  520. plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
  521. next unless File.exist?(plugin_file)
  522. begin
  523. # Load the plugin file
  524. load plugin_file
  525. plugin_class_name = candidate_name.classify
  526. plugin_class = plugin_class_name.constantize rescue nil
  527. if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
  528. # Create a temporary instance to check the name
  529. temp_instance = plugin_class.new
  530. if temp_instance.name == plugin.name
  531. return temp_instance
  532. end
  533. end
  534. rescue => e
  535. # Continue to next plugin if this one fails
  536. next
  537. end
  538. end
  539. nil
  540. end
  541. end

app/controllers/admin/posts_controller.rb

0.0% lines covered

100.0% branches covered

320 relevant lines. 0 lines covered and 320 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::PostsController < Admin::BaseController
  2. before_action :set_post, only: %i[ show edit update destroy publish unpublish write restore versions restore_version ]
  3. layout :choose_layout
  4. # GET /admin/posts or /admin/posts.json
  5. def index
  6. @posts = Post.kept.includes(:user, :terms).order(created_at: :desc)
  7. # Filter by status if specified
  8. if params[:status].present? && Post.statuses.keys.include?(params[:status])
  9. @posts = @posts.where(status: params[:status])
  10. end
  11. # Show trashed if explicitly requested
  12. if params[:show_trash] == 'true'
  13. @posts = Post.trashed.includes(:user, :terms).order(deleted_at: :desc)
  14. end
  15. respond_to do |format|
  16. format.html do
  17. @posts_data = posts_json
  18. @stats = {
  19. total: Post.kept.count,
  20. published: Post.published.count,
  21. draft: Post.where(status: 'draft').count,
  22. trash: Post.trashed.count
  23. }
  24. @bulk_actions = [
  25. { value: 'trash', label: 'Move to Trash' },
  26. { value: 'untrash', label: 'Restore' },
  27. { value: 'delete', label: 'Delete Permanently' }
  28. ]
  29. @status_options = [
  30. { value: 'published', label: 'Published' },
  31. { value: 'draft', label: 'Draft' },
  32. { value: 'pending', label: 'Pending' }
  33. ]
  34. @columns = [
  35. {
  36. title: "",
  37. formatter: "rowSelection",
  38. titleFormatter: "rowSelection",
  39. width: 40,
  40. headerSort: false
  41. },
  42. {
  43. title: "Title",
  44. field: "title",
  45. width: 300,
  46. formatter: "html"
  47. },
  48. {
  49. title: "Author",
  50. field: "author_name",
  51. width: 150
  52. },
  53. {
  54. title: "Status",
  55. field: "status",
  56. width: 100,
  57. formatter: "html"
  58. },
  59. {
  60. title: "Categories",
  61. field: "categories",
  62. width: 150,
  63. formatter: "html"
  64. },
  65. {
  66. title: "Tags",
  67. field: "tags",
  68. width: 150,
  69. formatter: "html"
  70. },
  71. {
  72. title: "Date",
  73. field: "created_at",
  74. width: 150,
  75. formatter: "datetime",
  76. formatterParams: {
  77. inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
  78. outputFormat: "DD/MM/YYYY HH:mm"
  79. }
  80. },
  81. {
  82. title: "Actions",
  83. field: "actions",
  84. width: 120,
  85. headerSort: false,
  86. formatter: "html"
  87. }
  88. ]
  89. end
  90. format.json { render json: posts_json }
  91. end
  92. end
  93. # GET /admin/posts/1 or /admin/posts/1.json
  94. def show
  95. end
  96. # GET /admin/posts/new
  97. def new
  98. @post = current_user.posts.build(status: :draft)
  99. @categories = Term.for_taxonomy('category').ordered
  100. @tags = Term.for_taxonomy('post_tag').ordered
  101. end
  102. # GET /admin/posts/write (collection)
  103. def write_new
  104. @post = current_user.posts.build(status: :draft)
  105. @categories = Term.for_taxonomy('category').ordered
  106. @tags = Term.for_taxonomy('post_tag').ordered
  107. render :write, layout: 'write_fullscreen'
  108. end
  109. # GET /admin/posts/:id/write (member)
  110. def write
  111. @categories = Term.for_taxonomy('category').ordered
  112. @tags = Term.for_taxonomy('post_tag').ordered
  113. render layout: 'write_fullscreen'
  114. end
  115. # GET /admin/posts/1/edit
  116. def edit
  117. @categories = Term.for_taxonomy('category').ordered
  118. @tags = Term.for_taxonomy('post_tag').ordered
  119. end
  120. # POST /admin/posts or /admin/posts.json
  121. def create
  122. @post = current_user.posts.build(post_params)
  123. # Handle unique slug generation for untitled posts
  124. if params[:post][:generate_unique_slug] == 'true' && @post.slug == 'untitled'
  125. @post.slug = generate_unique_untitled_slug
  126. end
  127. respond_to do |format|
  128. if @post.save
  129. if params[:autosave] == 'true'
  130. # Autosave response - redirect to edit page for continued editing
  131. format.json { render json: { status: 'success', id: @post.id, edit_url: admin_post_path(@post), slug: @post.slug } }
  132. else
  133. format.html { redirect_to [:admin, @post], notice: "Post was successfully created." }
  134. format.json { render :show, status: :created, location: @post }
  135. end
  136. else
  137. @categories = Term.for_taxonomy('category').ordered
  138. @tags = Term.for_taxonomy('post_tag').ordered
  139. if params[:autosave] == 'true'
  140. format.json { render json: { status: 'error', errors: @post.errors }, status: :unprocessable_entity }
  141. else
  142. format.html { render :new, status: :unprocessable_entity }
  143. format.json { render json: @post.errors, status: :unprocessable_entity }
  144. end
  145. end
  146. end
  147. end
  148. # PATCH/PUT /admin/posts/1 or /admin/posts/1.json
  149. def update
  150. respond_to do |format|
  151. if @post.update(post_params)
  152. if params[:autosave] == 'true'
  153. # Autosave response - just return success
  154. format.json { render json: { status: 'success', updated_at: @post.updated_at, slug: @post.slug } }
  155. else
  156. format.html { redirect_to [:admin, @post], notice: "Post was successfully updated.", status: :see_other }
  157. format.json { render :show, status: :ok, location: @post }
  158. end
  159. else
  160. @categories = Term.for_taxonomy('category').ordered
  161. @tags = Term.for_taxonomy('post_tag').ordered
  162. if params[:autosave] == 'true'
  163. format.json { render json: { status: 'error', errors: @post.errors }, status: :unprocessable_entity }
  164. else
  165. format.html { render :edit, status: :unprocessable_entity }
  166. format.json { render json: @post.errors, status: :unprocessable_entity }
  167. end
  168. end
  169. end
  170. end
  171. # DELETE /admin/posts/1 or /admin/posts/1.json
  172. def destroy
  173. if @post.trashed?
  174. @post.destroy_permanently! # Permanent delete
  175. notice = "Post was permanently deleted."
  176. else
  177. @post.trash!(current_user) # Soft delete
  178. notice = "Post was moved to trash."
  179. end
  180. respond_to do |format|
  181. format.html { redirect_to admin_posts_path, notice: notice, status: :see_other }
  182. format.json { head :no_content }
  183. end
  184. end
  185. # PATCH /admin/posts/1/publish
  186. def publish
  187. @post.update(status: :published, published_at: Time.current)
  188. redirect_to [:admin, @post], notice: "Post was successfully published."
  189. end
  190. # PATCH /admin/posts/1/unpublish
  191. def unpublish
  192. @post.update(status: :draft)
  193. redirect_to [:admin, @post], notice: "Post was unpublished."
  194. end
  195. # PATCH /admin/posts/1/restore
  196. def restore
  197. @post.untrash!
  198. redirect_to admin_posts_path, notice: "Post was restored from trash."
  199. end
  200. # POST /admin/posts/bulk_action
  201. def bulk_action
  202. action_type = params[:action_type]
  203. post_ids = params[:ids] || []
  204. posts = Post.where(id: post_ids)
  205. case action_type
  206. when 'publish'
  207. posts.update_all(status: :published, published_at: Time.current)
  208. message = "#{posts.count} posts published"
  209. when 'unpublish'
  210. posts.update_all(status: :draft)
  211. message = "#{posts.count} posts unpublished"
  212. when 'trash'
  213. posts.find_each { |post| post.trash!(current_user) }
  214. message = "#{posts.count} posts moved to trash"
  215. when 'untrash'
  216. posts.find_each(&:untrash!)
  217. message = "#{posts.count} posts restored from trash"
  218. when 'delete'
  219. posts.find_each(&:destroy_permanently!)
  220. message = "#{posts.count} posts permanently deleted"
  221. else
  222. message = "Invalid action"
  223. end
  224. respond_to do |format|
  225. format.json { render json: { success: true, message: message } }
  226. end
  227. end
  228. private
  229. # Use callbacks to share common setup or constraints between actions.
  230. def set_post
  231. @post = Post.friendly.find(params[:id])
  232. end
  233. # Only allow a list of trusted parameters through.
  234. def post_params
  235. params.require(:post).permit(
  236. :title, :slug, :content, :excerpt, :status, :published_at,
  237. :featured_image, :meta_description, :meta_keywords,
  238. :featured_image_file, :password, :password_hint,
  239. category_ids: [], tag_ids: []
  240. )
  241. end
  242. def generate_unique_untitled_slug
  243. base_slug = 'untitled'
  244. counter = 1
  245. loop do
  246. candidate_slug = counter == 1 ? base_slug : "#{base_slug}-#{counter}"
  247. # Check if slug exists (considering tenant scope if using multi-tenancy)
  248. existing_post = current_user.posts.where(slug: candidate_slug).first
  249. unless existing_post
  250. return candidate_slug
  251. end
  252. counter += 1
  253. # Prevent infinite loop
  254. break if counter > 1000
  255. end
  256. # Fallback to timestamp-based slug if we hit the limit
  257. "#{base_slug}-#{Time.current.to_i}"
  258. end
  259. def posts_json
  260. @posts.map do |post|
  261. categories = post.terms_for_taxonomy('category').pluck(:name)
  262. tags = post.terms_for_taxonomy('post_tag').pluck(:name)
  263. {
  264. id: post.id,
  265. title: "<a href=\"#{edit_admin_post_path(post)}\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">#{post.title}</a>",
  266. slug: post.slug,
  267. status: format_status_badge(post.status),
  268. status_raw: post.status, # Raw status for CSS classes
  269. author_name: post.user&.name || 'Unknown',
  270. categories: format_categories(categories),
  271. tags: format_tags(tags),
  272. comments_count: post.comments.where(status: 'approved').count,
  273. created_at: post.created_at.iso8601,
  274. published_at: post.published_at&.iso8601,
  275. actions: format_actions(post),
  276. edit_url: edit_admin_post_path(post),
  277. show_url: admin_post_path(post),
  278. delete_url: admin_post_path(post)
  279. }
  280. end
  281. end
  282. private
  283. def format_status_badge(status)
  284. status_map = {
  285. 'published' => { class: 'bg-green-100 text-green-800', label: 'Published' },
  286. 'draft' => { class: 'bg-yellow-100 text-yellow-800', label: 'Draft' },
  287. 'pending' => { class: 'bg-blue-100 text-blue-800', label: 'Pending' },
  288. 'trash' => { class: 'bg-red-100 text-red-800', label: 'Trash' }
  289. }
  290. status_info = status_map[status] || { class: 'bg-gray-100 text-gray-800', label: status }
  291. "<span class=\"px-2 py-1 text-xs font-medium rounded-full #{status_info[:class]}\">#{status_info[:label]}</span>"
  292. end
  293. def format_categories(categories)
  294. if categories.empty?
  295. '<span class="text-gray-400">Uncategorized</span>'
  296. else
  297. categories.map { |cat| "<span class=\"px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded mr-1\">#{cat}</span>" }.join('')
  298. end
  299. end
  300. def format_tags(tags)
  301. if tags.empty?
  302. ''
  303. else
  304. tags.map { |tag| "<span class=\"px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded mr-1\">#{tag}</span>" }.join('')
  305. end
  306. end
  307. def format_actions(post)
  308. actions = ''
  309. actions += "<a href=\"#{edit_admin_post_path(post)}\" class=\"text-indigo-600 hover:text-indigo-900 mr-2\" title=\"Edit\">✏️</a>"
  310. actions += "<a href=\"#{admin_post_path(post)}\" class=\"text-blue-600 hover:text-blue-900 mr-2\" title=\"View\">👁️</a>"
  311. actions += "<a href=\"#{admin_post_path(post)}\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\" data-confirm=\"Are you sure?\">🗑️</a>"
  312. actions
  313. end
  314. def choose_layout
  315. action_name == 'write' || action_name == 'write_new' ? 'write_fullscreen' : 'admin'
  316. end
  317. # Version-related actions
  318. def versions
  319. @versions = @post.versions.includes(:user).order(created_at: :desc)
  320. respond_to do |format|
  321. format.html
  322. format.json { render json: @versions.map { |v| version_json(v) } }
  323. end
  324. end
  325. def restore_version
  326. version_id = params[:version_id]
  327. if @post.restore_to_version(version_id)
  328. redirect_to edit_admin_post_path(@post), notice: 'Version restored successfully!'
  329. else
  330. redirect_to versions_admin_post_path(@post), alert: 'Failed to restore version.'
  331. end
  332. end
  333. private
  334. def version_json(version)
  335. {
  336. id: version.id,
  337. created_at: version.created_at,
  338. user: version.whodunnit ? User.find_by(id: version.whodunnit)&.name : 'System',
  339. summary: @post.version_summary(version),
  340. changes: version.changeset.keys,
  341. event: version.event
  342. }
  343. end
  344. end

app/controllers/admin/profile_controller.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ProfileController < Admin::BaseController
  2. before_action :set_user
  3. # GET /admin/profile
  4. def show
  5. redirect_to edit_admin_profile_path
  6. end
  7. # GET /admin/profile/edit
  8. def edit
  9. @available_editors = available_editors
  10. end
  11. # PATCH /admin/profile
  12. def update
  13. user_params_filtered = user_params
  14. # Remove password params if not provided
  15. if params[:user][:password].blank?
  16. user_params_filtered = user_params_filtered.except(:password, :password_confirmation)
  17. end
  18. # Handle avatar upload
  19. if params[:user][:avatar].present?
  20. @user.avatar.attach(params[:user][:avatar])
  21. end
  22. if @user.update(user_params_filtered)
  23. redirect_to edit_admin_profile_path, notice: 'Profile updated successfully.'
  24. else
  25. render :edit, status: :unprocessable_entity
  26. end
  27. end
  28. # DELETE /admin/profile/avatar
  29. def remove_avatar
  30. @user.avatar.purge if @user.avatar.attached?
  31. redirect_to edit_admin_profile_path, notice: 'Avatar removed successfully.'
  32. end
  33. private
  34. def set_user
  35. @user = current_user
  36. end
  37. def user_params
  38. params.require(:user).permit(:email, :name, :password, :password_confirmation, :avatar, :bio, :website, :twitter, :github, :linkedin, :phone, :location, :avatar_url, :editor_preference)
  39. end
  40. def available_editors
  41. [
  42. ['BlockNote (Modern Block Editor)', 'blocknote'],
  43. ['Editor.js (Rich Block Editor)', 'editorjs'],
  44. ['Trix (Rich Text Editor)', 'trix'],
  45. ['Simple Textarea', 'simple']
  46. ]
  47. end
  48. end

app/controllers/admin/redirects_controller.rb

0.0% lines covered

100.0% branches covered

119 relevant lines. 0 lines covered and 119 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::RedirectsController < Admin::BaseController
  2. before_action :set_redirect, only: [:edit, :update, :destroy, :toggle]
  3. # GET /admin/redirects
  4. def index
  5. @redirects = Redirect.includes(:versions)
  6. .order(created_at: :desc)
  7. .page(params[:page])
  8. .per(20)
  9. # Filter by status
  10. @redirects = @redirects.active if params[:status] == 'active'
  11. @redirects = @redirects.inactive if params[:status] == 'inactive'
  12. # Filter by type
  13. @redirects = @redirects.by_type(params[:type]) if params[:type].present?
  14. # Search
  15. if params[:search].present?
  16. search_term = "%#{params[:search]}%"
  17. @redirects = @redirects.where('from_path LIKE ? OR to_path LIKE ?', search_term, search_term)
  18. end
  19. # Stats
  20. @stats = {
  21. total: Redirect.count,
  22. active: Redirect.active.count,
  23. inactive: Redirect.inactive.count,
  24. total_hits: Redirect.sum(:hits_count)
  25. }
  26. end
  27. # GET /admin/redirects/new
  28. def new
  29. @redirect = Redirect.new
  30. end
  31. # GET /admin/redirects/:id/edit
  32. def edit
  33. end
  34. # POST /admin/redirects
  35. def create
  36. @redirect = Redirect.new(redirect_params)
  37. if @redirect.save
  38. redirect_to admin_redirects_path, notice: 'Redirect created successfully.'
  39. else
  40. render :new, status: :unprocessable_entity
  41. end
  42. end
  43. # PATCH/PUT /admin/redirects/:id
  44. def update
  45. if @redirect.update(redirect_params)
  46. redirect_to admin_redirects_path, notice: 'Redirect updated successfully.'
  47. else
  48. render :edit, status: :unprocessable_entity
  49. end
  50. end
  51. # DELETE /admin/redirects/:id
  52. def destroy
  53. @redirect.destroy
  54. redirect_to admin_redirects_path, notice: 'Redirect deleted successfully.'
  55. end
  56. # PATCH /admin/redirects/:id/toggle
  57. def toggle
  58. @redirect.update(active: !@redirect.active)
  59. redirect_to admin_redirects_path, notice: "Redirect #{@redirect.active? ? 'activated' : 'deactivated'}."
  60. end
  61. # POST /admin/redirects/bulk_action
  62. def bulk_action
  63. redirect_ids = params[:redirect_ids] || []
  64. action = params[:bulk_action]
  65. case action
  66. when 'activate'
  67. Redirect.where(id: redirect_ids).update_all(active: true)
  68. message = "#{redirect_ids.count} redirects activated."
  69. when 'deactivate'
  70. Redirect.where(id: redirect_ids).update_all(active: false)
  71. message = "#{redirect_ids.count} redirects deactivated."
  72. when 'delete'
  73. Redirect.where(id: redirect_ids).destroy_all
  74. message = "#{redirect_ids.count} redirects deleted."
  75. else
  76. message = "Invalid action."
  77. end
  78. redirect_to admin_redirects_path, notice: message
  79. end
  80. # GET /admin/redirects/import
  81. def import
  82. end
  83. # POST /admin/redirects/do_import
  84. def do_import
  85. unless params[:file].present?
  86. redirect_to import_admin_redirects_path, alert: 'Please select a file to import.'
  87. return
  88. end
  89. file = params[:file]
  90. begin
  91. require 'csv'
  92. csv_data = CSV.parse(file.read, headers: true)
  93. data = csv_data.map do |row|
  94. {
  95. from_path: row['From Path'] || row['from_path'],
  96. to_path: row['To Path'] || row['to_path'],
  97. redirect_type: row['Type'] || row['type'] || 'permanent',
  98. notes: row['Notes'] || row['notes']
  99. }
  100. end
  101. result = Redirect.import_redirects(data)
  102. if result[:errors].empty?
  103. redirect_to admin_redirects_path, notice: "Successfully imported #{result[:imported]} redirects."
  104. else
  105. flash[:alert] = "Imported #{result[:imported]} redirects with #{result[:errors].count} errors."
  106. redirect_to admin_redirects_path
  107. end
  108. rescue => e
  109. redirect_to import_admin_redirects_path, alert: "Import failed: #{e.message}"
  110. end
  111. end
  112. # GET /admin/redirects/export
  113. def export
  114. csv_data = Redirect.to_csv
  115. send_data csv_data,
  116. filename: "redirects-#{Date.today}.csv",
  117. type: 'text/csv',
  118. disposition: 'attachment'
  119. end
  120. private
  121. def set_redirect
  122. @redirect = Redirect.find(params[:id])
  123. end
  124. def redirect_params
  125. params.require(:redirect).permit(
  126. :from_path,
  127. :to_path,
  128. :redirect_type,
  129. :status_code,
  130. :active,
  131. :notes
  132. )
  133. end
  134. end

app/controllers/admin/security_controller.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::SecurityController < Admin::BaseController
  2. before_action :set_user
  3. # GET /admin/security
  4. def show
  5. @login_history = load_login_history
  6. @active_sessions = load_active_sessions
  7. end
  8. # PATCH /admin/security/update_password
  9. def update_password
  10. unless @user.valid_password?(params[:current_password])
  11. redirect_to admin_security_path, alert: 'Current password is incorrect.'
  12. return
  13. end
  14. if params[:new_password] != params[:confirm_password]
  15. redirect_to admin_security_path, alert: 'New passwords do not match.'
  16. return
  17. end
  18. if @user.update(password: params[:new_password], password_confirmation: params[:confirm_password])
  19. # Sign in again after password change
  20. sign_in(@user, bypass: true)
  21. redirect_to admin_security_path, notice: 'Password updated successfully.'
  22. else
  23. redirect_to admin_security_path, alert: 'Failed to update password. Must be at least 6 characters.'
  24. end
  25. end
  26. # POST /admin/security/enable_2fa
  27. def enable_2fa
  28. # Placeholder for 2FA implementation
  29. redirect_to admin_security_path, notice: 'Two-factor authentication feature coming soon.'
  30. end
  31. # DELETE /admin/security/disable_2fa
  32. def disable_2fa
  33. # Placeholder for 2FA implementation
  34. redirect_to admin_security_path, notice: 'Two-factor authentication disabled.'
  35. end
  36. # POST /admin/security/regenerate_api_token
  37. def regenerate_api_token
  38. @user.regenerate_api_token!
  39. redirect_to admin_security_path, notice: 'API token regenerated successfully.'
  40. end
  41. # DELETE /admin/security/revoke_sessions
  42. def revoke_sessions
  43. # This would revoke all other sessions except current
  44. # Implementation depends on session management strategy
  45. redirect_to admin_security_path, notice: 'All other sessions have been revoked.'
  46. end
  47. private
  48. def set_user
  49. @user = current_user
  50. end
  51. def load_login_history
  52. # Placeholder - would come from a login_history table
  53. []
  54. end
  55. def load_active_sessions
  56. # Placeholder - would come from sessions tracking
  57. []
  58. end
  59. end

app/controllers/admin/sessions_controller.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::SessionsController < Devise::SessionsController
  2. layout 'admin_login'
  3. helper AppearanceHelper
  4. # Temporarily disable CSRF protection for admin login to fix the issue
  5. skip_before_action :verify_authenticity_token, only: [:create]
  6. # Override after_sign_in to check admin access
  7. def after_sign_in_path_for(resource)
  8. # Check if user has admin access
  9. unless resource.author? || resource.editor? || resource.administrator?
  10. sign_out(resource)
  11. flash[:alert] = 'You do not have permission to access the admin area.'
  12. new_admin_user_session_path
  13. else
  14. admin_root_path
  15. end
  16. end
  17. # Override to redirect to admin login after logout
  18. def after_sign_out_path_for(resource_or_scope)
  19. new_admin_user_session
  20. end
  21. end

app/controllers/admin/settings/storage_controller.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Settings::StorageController < ApplicationController
  2. def index
  3. end
  4. def new
  5. end
  6. def create
  7. end
  8. def edit
  9. end
  10. def update
  11. end
  12. def destroy
  13. end
  14. end

app/controllers/admin/settings/upload_security_controller.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Settings::UploadSecurityController < Admin::BaseController
  2. before_action :set_upload_security
  3. # GET /admin/settings/upload_security
  4. def show
  5. end
  6. # PATCH /admin/settings/upload_security
  7. def update
  8. if @upload_security.update(upload_security_params)
  9. redirect_to admin_settings_upload_security_path, notice: "Upload security settings updated successfully."
  10. else
  11. render :show, status: :unprocessable_entity
  12. end
  13. end
  14. private
  15. def set_upload_security
  16. @upload_security = UploadSecurity.current
  17. end
  18. def upload_security_params
  19. params.require(:upload_security).permit(
  20. :max_file_size_human,
  21. :allowed_extensions_list,
  22. :blocked_extensions_list,
  23. :allowed_mime_types_list,
  24. :blocked_mime_types_list,
  25. :scan_for_viruses,
  26. :quarantine_suspicious,
  27. :auto_approve_trusted
  28. )
  29. end
  30. end

app/controllers/admin/settings_controller.rb

0.0% lines covered

100.0% branches covered

420 relevant lines. 0 lines covered and 420 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::SettingsController < Admin::BaseController
  2. before_action :ensure_admin
  3. # GET /admin/settings
  4. def index
  5. redirect_to admin_general_settings_path
  6. end
  7. # GET /admin/settings/general
  8. def general
  9. load_general_settings
  10. end
  11. # GET /admin/settings/writing
  12. def writing
  13. load_writing_settings
  14. end
  15. # GET /admin/settings/reading
  16. def reading
  17. load_reading_settings
  18. end
  19. # GET /admin/settings/discussion
  20. def discussion
  21. load_discussion_settings
  22. end
  23. # GET /admin/settings/media
  24. def media
  25. load_media_settings
  26. end
  27. # GET /admin/settings/permalinks
  28. def permalinks
  29. load_permalink_settings
  30. end
  31. # GET /admin/settings/privacy
  32. def privacy
  33. load_privacy_settings
  34. end
  35. # GET /admin/settings/email
  36. def email
  37. load_email_settings
  38. end
  39. # GET /admin/settings/post_by_email
  40. def post_by_email
  41. # Settings are loaded dynamically in the view
  42. end
  43. # GET /admin/settings/white_label
  44. def white_label
  45. load_white_label_settings
  46. end
  47. # GET /admin/settings/appearance
  48. def appearance
  49. load_appearance_settings
  50. end
  51. # GET /admin/settings/storage
  52. def storage
  53. load_storage_settings
  54. end
  55. # PATCH /admin/settings/update_general
  56. def update_general
  57. params[:settings].each do |key, value|
  58. SiteSetting.set(key, value, setting_type_for(key))
  59. end
  60. redirect_to admin_general_settings_path, notice: 'General settings updated successfully.'
  61. end
  62. # PATCH /admin/settings/update_writing
  63. def update_writing
  64. # Update site settings
  65. if params[:settings]
  66. params[:settings].each do |key, value|
  67. SiteSetting.set(key, value, setting_type_for(key))
  68. end
  69. end
  70. # Update user's editor preference
  71. if params[:user] && params[:user][:editor_preference]
  72. current_user.update(editor_preference: params[:user][:editor_preference])
  73. end
  74. redirect_to admin_writing_settings_path, notice: 'Writing settings updated successfully.'
  75. end
  76. # PATCH /admin/settings/update_reading
  77. def update_reading
  78. params[:settings].each do |key, value|
  79. SiteSetting.set(key, value, setting_type_for(key))
  80. end
  81. redirect_to admin_reading_settings_path, notice: 'Reading settings updated successfully.'
  82. end
  83. # PATCH /admin/settings/update_discussion
  84. def update_discussion
  85. params[:settings].each do |key, value|
  86. SiteSetting.set(key, value, setting_type_for(key))
  87. end
  88. redirect_to admin_discussion_settings_path, notice: 'Discussion settings updated successfully.'
  89. end
  90. # PATCH /admin/settings/update_media
  91. def update_media
  92. params[:settings].each do |key, value|
  93. SiteSetting.set(key, value, setting_type_for(key))
  94. end
  95. redirect_to admin_media_settings_path, notice: 'Media settings updated successfully.'
  96. end
  97. # PATCH /admin/settings/update_permalinks
  98. def update_permalinks
  99. params[:settings].each do |key, value|
  100. SiteSetting.set(key, value, setting_type_for(key))
  101. end
  102. redirect_to admin_permalinks_settings_path, notice: 'Permalink settings updated successfully.'
  103. end
  104. # PATCH /admin/settings/update_privacy
  105. def update_privacy
  106. params[:settings].each do |key, value|
  107. SiteSetting.set(key, value, setting_type_for(key))
  108. end
  109. redirect_to admin_privacy_settings_path, notice: 'Privacy settings updated successfully.'
  110. end
  111. # PATCH /admin/settings/update_email
  112. def update_email
  113. params[:settings].each do |key, value|
  114. SiteSetting.set(key, value, setting_type_for(key))
  115. end
  116. # Apply email configuration
  117. configure_action_mailer
  118. redirect_to admin_email_settings_path, notice: 'Email settings updated successfully.'
  119. end
  120. # POST /admin/settings/test_email
  121. def test_email
  122. provider = SiteSetting.get('email_provider', 'smtp')
  123. begin
  124. TestMailer.test_email(params[:test_email_address]).deliver_now
  125. render json: {
  126. success: true,
  127. message: "Test email sent successfully via #{provider.upcase}!"
  128. }
  129. rescue => e
  130. render json: {
  131. success: false,
  132. message: "Failed to send test email: #{e.message}"
  133. }, status: :unprocessable_entity
  134. end
  135. end
  136. # PATCH /admin/settings/update_post_by_email
  137. def update_post_by_email
  138. # Save all post by email settings
  139. [
  140. 'post_by_email_enabled',
  141. 'imap_server',
  142. 'imap_port',
  143. 'imap_email',
  144. 'imap_password',
  145. 'imap_ssl',
  146. 'imap_folder',
  147. 'post_by_email_default_category',
  148. 'post_by_email_default_author',
  149. 'post_by_email_mark_as_read',
  150. 'post_by_email_delete_after_import'
  151. ].each do |key|
  152. value = params[key]
  153. if value.present?
  154. SiteSetting.set(key, value, setting_type_for(key))
  155. elsif key == 'post_by_email_enabled' || key == 'post_by_email_mark_as_read' || key == 'post_by_email_delete_after_import'
  156. # Handle unchecked checkboxes
  157. SiteSetting.set(key, false, 'boolean')
  158. end
  159. end
  160. render json: { success: true, message: 'Post by Email settings saved successfully!' }
  161. rescue => e
  162. Rails.logger.error "Error saving post by email settings: #{e.message}"
  163. Rails.logger.error e.backtrace.join("\n")
  164. render json: { success: false, error: e.message }, status: :unprocessable_entity
  165. end
  166. # POST /admin/settings/test_post_by_email
  167. def test_post_by_email
  168. begin
  169. result = PostByEmailService.check_mail
  170. render json: {
  171. success: true,
  172. message: "#{result[:new_posts]} new post(s) created, #{result[:checked]} email(s) checked"
  173. }
  174. rescue => e
  175. Rails.logger.error "Error testing post by email: #{e.message}"
  176. render json: {
  177. success: false,
  178. error: "Connection failed: #{e.message}"
  179. }, status: :unprocessable_entity
  180. end
  181. end
  182. # PATCH /admin/settings/update_white_label
  183. def update_white_label
  184. # Handle logo upload if present
  185. if params[:settings] && params[:settings][:admin_logo].present?
  186. # Store logo using ActiveStorage or similar
  187. logo_file = params[:settings][:admin_logo]
  188. SiteSetting.set('admin_logo_url', store_logo(logo_file), :string)
  189. params[:settings].delete(:admin_logo)
  190. end
  191. params[:settings].each do |key, value|
  192. SiteSetting.set(key, value, setting_type_for(key))
  193. end
  194. redirect_to admin_settings_white_label_path, notice: 'White label settings updated successfully.'
  195. end
  196. # PATCH /admin/settings/update_appearance
  197. def update_appearance
  198. params[:settings].each do |key, value|
  199. SiteSetting.set(key, value, setting_type_for(key))
  200. end
  201. redirect_to admin_settings_appearance_path, notice: 'Appearance settings updated successfully.'
  202. end
  203. # PATCH /admin/settings/update_storage
  204. def update_storage
  205. # Update storage settings
  206. if params[:settings]
  207. params[:settings].each do |key, value|
  208. SiteSetting.set(key, value, setting_type_for(key))
  209. end
  210. end
  211. # Update tenant storage configuration if we have a current tenant
  212. if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
  213. tenant = ActsAsTenant.current_tenant
  214. tenant.update!(
  215. storage_type: params[:storage_type] || 'local',
  216. storage_bucket: params[:storage_bucket],
  217. storage_region: params[:storage_region],
  218. storage_access_key: params[:storage_access_key],
  219. storage_secret_key: params[:storage_secret_key],
  220. storage_endpoint: params[:storage_endpoint],
  221. storage_path: params[:storage_path]
  222. )
  223. end
  224. # Apply storage configuration
  225. begin
  226. storage_config = StorageConfigurationService.new
  227. storage_config.configure_active_storage
  228. storage_config.update_storage_config
  229. rescue => e
  230. Rails.logger.error "Failed to apply storage configuration: #{e.message}"
  231. redirect_to admin_storage_settings_path, alert: 'Storage settings updated but configuration failed to apply. Please check the logs.'
  232. return
  233. end
  234. redirect_to admin_storage_settings_path, notice: 'Storage settings updated successfully.'
  235. end
  236. private
  237. def load_general_settings
  238. @settings = {
  239. site_title: SiteSetting.get('site_title', 'RailsPress'),
  240. site_tagline: SiteSetting.get('site_tagline', 'A Ruby on Rails CMS'),
  241. site_url: SiteSetting.get('site_url', 'http://localhost:3000'),
  242. admin_email: SiteSetting.get('admin_email', 'admin@railspress.com'),
  243. timezone: SiteSetting.get('timezone', 'UTC'),
  244. date_format: SiteSetting.get('date_format', '%B %d, %Y'),
  245. time_format: SiteSetting.get('time_format', '%H:%M'),
  246. language: SiteSetting.get('language', 'en')
  247. }
  248. end
  249. def load_writing_settings
  250. @settings = {
  251. default_post_status: SiteSetting.get('default_post_status', 'draft'),
  252. default_post_category: SiteSetting.get('default_post_category', ''),
  253. default_post_format: SiteSetting.get('default_post_format', 'standard'),
  254. enable_auto_save: SiteSetting.get('enable_auto_save', true),
  255. auto_save_interval: SiteSetting.get('auto_save_interval', 60),
  256. enable_revisions: SiteSetting.get('enable_revisions', true),
  257. max_revisions: SiteSetting.get('max_revisions', 10),
  258. rich_editor_type: SiteSetting.get('rich_editor_type', 'trix')
  259. }
  260. end
  261. def load_reading_settings
  262. @settings = {
  263. posts_per_page: SiteSetting.get('posts_per_page', 10),
  264. posts_per_rss: SiteSetting.get('posts_per_rss', 10),
  265. homepage_display: SiteSetting.get('homepage_display', 'posts'),
  266. homepage_page_id: SiteSetting.get('homepage_page_id', ''),
  267. blog_page_id: SiteSetting.get('blog_page_id', ''),
  268. show_on_front: SiteSetting.get('show_on_front', 'posts'),
  269. excerpt_length: SiteSetting.get('excerpt_length', 200)
  270. }
  271. end
  272. def load_media_settings
  273. @settings = {
  274. image_max_width: SiteSetting.get('image_max_width', 2048),
  275. image_max_height: SiteSetting.get('image_max_height', 2048),
  276. thumbnail_width: SiteSetting.get('thumbnail_width', 150),
  277. thumbnail_height: SiteSetting.get('thumbnail_height', 150),
  278. medium_width: SiteSetting.get('medium_width', 300),
  279. medium_height: SiteSetting.get('medium_height', 300),
  280. large_width: SiteSetting.get('large_width', 1024),
  281. large_height: SiteSetting.get('large_height', 1024),
  282. auto_optimize_images: SiteSetting.get('auto_optimize_images', false),
  283. # System-wide compression level settings
  284. image_compression_level: SiteSetting.get('image_compression_level', 'lossy'),
  285. image_quality: SiteSetting.get('image_quality', 85),
  286. image_compression_level_value: SiteSetting.get('image_compression_level_value', 6),
  287. strip_image_metadata: SiteSetting.get('strip_image_metadata', true),
  288. enable_webp_variants: SiteSetting.get('enable_webp_variants', true),
  289. enable_avif_variants: SiteSetting.get('enable_avif_variants', true),
  290. allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx'),
  291. max_upload_size: SiteSetting.get('max_upload_size', 10)
  292. }
  293. end
  294. def load_permalink_settings
  295. @settings = {
  296. permalink_structure: SiteSetting.get('permalink_structure', '/blog/:slug'),
  297. category_base: SiteSetting.get('category_base', 'category'),
  298. tag_base: SiteSetting.get('tag_base', 'tag'),
  299. use_trailing_slash: SiteSetting.get('use_trailing_slash', false),
  300. auto_redirect_old_urls: SiteSetting.get('auto_redirect_old_urls', true)
  301. }
  302. end
  303. def load_discussion_settings
  304. @settings = {
  305. comments_enabled: SiteSetting.get('comments_enabled', true),
  306. comments_moderation: SiteSetting.get('comments_moderation', true),
  307. comment_registration_required: SiteSetting.get('comment_registration_required', false),
  308. close_comments_after_days: SiteSetting.get('close_comments_after_days', 0),
  309. show_avatars: SiteSetting.get('show_avatars', true),
  310. akismet_api_key: SiteSetting.get('akismet_api_key', ''),
  311. akismet_enabled: SiteSetting.get('akismet_enabled', false)
  312. }
  313. end
  314. def load_privacy_settings
  315. @settings = {
  316. gdpr_compliance_enabled: SiteSetting.get('gdpr_compliance_enabled', false),
  317. cookie_consent_required: SiteSetting.get('cookie_consent_required', false),
  318. privacy_policy_page_id: SiteSetting.get('privacy_policy_page_id', ''),
  319. allow_user_registration: SiteSetting.get('allow_user_registration', true),
  320. default_user_role: SiteSetting.get('default_user_role', 'subscriber'),
  321. # Analytics Privacy Settings
  322. analytics_enabled: SiteSetting.get('analytics_enabled', true),
  323. analytics_require_consent: SiteSetting.get('analytics_require_consent', true),
  324. analytics_anonymize_ip: SiteSetting.get('analytics_anonymize_ip', true),
  325. analytics_track_bots: SiteSetting.get('analytics_track_bots', false),
  326. analytics_data_retention_days: SiteSetting.get('analytics_data_retention_days', 365),
  327. analytics_consent_message: SiteSetting.get('analytics_consent_message', 'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.'),
  328. # High-Volume Performance Settings
  329. analytics_high_volume_mode: SiteSetting.get('analytics_high_volume_mode', false),
  330. analytics_archive_enabled: SiteSetting.get('analytics_archive_enabled', true),
  331. analytics_batch_size: SiteSetting.get('analytics_batch_size', 1000)
  332. }
  333. end
  334. def load_email_settings
  335. @settings = {
  336. email_provider: SiteSetting.get('email_provider', 'smtp'),
  337. email_logging_enabled: SiteSetting.get('email_logging_enabled', true),
  338. # SMTP
  339. smtp_host: SiteSetting.get('smtp_host', 'smtp.gmail.com'),
  340. smtp_port: SiteSetting.get('smtp_port', 587),
  341. smtp_encryption: SiteSetting.get('smtp_encryption', 'tls'),
  342. smtp_username: SiteSetting.get('smtp_username', ''),
  343. smtp_password: SiteSetting.get('smtp_password', ''),
  344. smtp_timeout: SiteSetting.get('smtp_timeout', 10),
  345. # Resend
  346. resend_api_key: SiteSetting.get('resend_api_key', ''),
  347. # Default sender
  348. default_from_email: SiteSetting.get('default_from_email', 'noreply@railspress.com'),
  349. default_from_name: SiteSetting.get('default_from_name', 'RailsPress')
  350. }
  351. end
  352. def load_white_label_settings
  353. @settings = {
  354. admin_app_name: SiteSetting.get('admin_app_name', 'RailsPress'),
  355. admin_app_url: SiteSetting.get('admin_app_url', 'http://localhost:3000'),
  356. admin_logo_url: SiteSetting.get('admin_logo_url', ''),
  357. admin_favicon_url: SiteSetting.get('admin_favicon_url', ''),
  358. admin_footer_text: SiteSetting.get('admin_footer_text', 'Powered by RailsPress'),
  359. admin_support_email: SiteSetting.get('admin_support_email', 'support@railspress.com'),
  360. admin_support_url: SiteSetting.get('admin_support_url', 'https://railspress.com/support'),
  361. hide_branding: SiteSetting.get('hide_branding', false)
  362. }
  363. end
  364. def load_appearance_settings
  365. @settings = {
  366. # Color Scheme
  367. color_scheme: SiteSetting.get('color_scheme', 'onyx'),
  368. # Color Accents
  369. primary_color: SiteSetting.get('primary_color', '#6366F1'),
  370. secondary_color: SiteSetting.get('secondary_color', '#8B5CF6'),
  371. # Typography
  372. heading_font: SiteSetting.get('heading_font', 'Inter'),
  373. body_font: SiteSetting.get('body_font', 'Inter'),
  374. paragraph_font: SiteSetting.get('paragraph_font', 'Inter'),
  375. # Font Sizes
  376. heading_size: SiteSetting.get('heading_size', '1.875rem'),
  377. body_size: SiteSetting.get('body_size', '0.875rem'),
  378. paragraph_size: SiteSetting.get('paragraph_size', '1rem')
  379. }
  380. end
  381. def load_storage_settings
  382. # Get current tenant storage settings if available
  383. current_tenant = defined?(ActsAsTenant) ? ActsAsTenant.current_tenant : nil
  384. @settings = {
  385. # Storage Type
  386. storage_type: current_tenant&.storage_type || SiteSetting.get('storage_type', 'local'),
  387. # Local Storage Configuration
  388. local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
  389. # S3 Configuration
  390. storage_bucket: current_tenant&.storage_bucket || SiteSetting.get('storage_bucket', ''),
  391. storage_region: current_tenant&.storage_region || SiteSetting.get('storage_region', 'us-east-1'),
  392. storage_access_key: current_tenant&.storage_access_key || SiteSetting.get('storage_access_key', ''),
  393. storage_secret_key: current_tenant&.storage_secret_key || SiteSetting.get('storage_secret_key', ''),
  394. storage_endpoint: current_tenant&.storage_endpoint || SiteSetting.get('storage_endpoint', ''),
  395. storage_path: current_tenant&.storage_path || SiteSetting.get('storage_path', ''),
  396. # General Storage Settings
  397. enable_cdn: SiteSetting.get('enable_cdn', false),
  398. cdn_url: SiteSetting.get('cdn_url', ''),
  399. auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true),
  400. max_file_size: SiteSetting.get('max_file_size', 10), # MB
  401. allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3')
  402. }
  403. end
  404. def configure_action_mailer
  405. provider = SiteSetting.get('email_provider', 'smtp')
  406. if provider == 'smtp'
  407. ActionMailer::Base.delivery_method = :smtp
  408. ActionMailer::Base.smtp_settings = {
  409. address: SiteSetting.get('smtp_host', 'smtp.gmail.com'),
  410. port: SiteSetting.get('smtp_port', 587).to_i,
  411. domain: SiteSetting.get('site_url', 'localhost'),
  412. user_name: SiteSetting.get('smtp_username', ''),
  413. password: SiteSetting.get('smtp_password', ''),
  414. authentication: 'plain',
  415. enable_starttls_auto: SiteSetting.get('smtp_encryption', 'tls') == 'tls',
  416. open_timeout: SiteSetting.get('smtp_timeout', 10).to_i,
  417. read_timeout: SiteSetting.get('smtp_timeout', 10).to_i
  418. }
  419. elsif provider == 'resend'
  420. # Resend uses its own delivery method
  421. ActionMailer::Base.delivery_method = :resend
  422. end
  423. end
  424. def setting_type_for(key)
  425. boolean_settings = %w[
  426. enable_auto_save enable_revisions auto_optimize_images use_trailing_slash
  427. auto_redirect_old_urls comments_enabled comments_moderation
  428. comment_registration_required show_avatars gdpr_compliance_enabled
  429. cookie_consent_required allow_user_registration email_logging_enabled
  430. hide_branding enable_cdn auto_optimize_uploads strip_image_metadata
  431. enable_webp_variants enable_avif_variants analytics_enabled
  432. analytics_require_consent analytics_anonymize_ip analytics_track_bots
  433. ]
  434. integer_settings = %w[
  435. auto_save_interval max_revisions posts_per_page posts_per_rss
  436. image_max_width image_max_height thumbnail_width thumbnail_height
  437. medium_width medium_height large_width large_height max_upload_size
  438. close_comments_after_days excerpt_length smtp_port smtp_timeout
  439. max_file_size image_quality image_compression_level_value
  440. analytics_data_retention_days
  441. ]
  442. if boolean_settings.include?(key)
  443. 'boolean'
  444. elsif integer_settings.include?(key)
  445. 'integer'
  446. else
  447. 'string'
  448. end
  449. end
  450. def store_logo(file)
  451. # For now, just return a placeholder
  452. # In production, you'd upload to ActiveStorage or external service
  453. return '/uploads/logo.png'
  454. end
  455. def shortcuts
  456. # Load command palette shortcut settings
  457. @command_palette_shortcut = SiteSetting.get('command_palette_shortcut', 'cmd+k')
  458. end
  459. def update_shortcuts
  460. shortcut = params[:command_palette_shortcut]
  461. # Validate shortcut format
  462. valid_shortcuts = ['cmd+k', 'ctrl+k', 'cmd+shift+p', 'ctrl+shift+p', 'cmd+i', 'ctrl+i']
  463. unless valid_shortcuts.include?(shortcut)
  464. redirect_to admin_shortcuts_settings_path, alert: 'Invalid shortcut format'
  465. return
  466. end
  467. SiteSetting.set('command_palette_shortcut', shortcut)
  468. redirect_to admin_shortcuts_settings_path, notice: 'Shortcuts updated successfully!'
  469. end
  470. # JSON endpoint for JavaScript to get shortcut settings
  471. def shortcuts_json
  472. render json: {
  473. command_palette_shortcut: SiteSetting.get('command_palette_shortcut', 'cmd+k')
  474. }
  475. end
  476. def ensure_admin
  477. redirect_to admin_root_path, alert: 'Access denied.' unless current_user&.administrator?
  478. end
  479. end

app/controllers/admin/shortcodes_controller.rb

0.0% lines covered

100.0% branches covered

100 relevant lines. 0 lines covered and 100 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ShortcodesController < Admin::BaseController
  2. # GET /admin/shortcodes
  3. def index
  4. @shortcodes = build_shortcode_list
  5. end
  6. # POST /admin/shortcodes/test
  7. def test
  8. content = params[:content]
  9. result = Railspress::ShortcodeProcessor.process(content)
  10. render json: {
  11. success: true,
  12. original: content,
  13. processed: result
  14. }
  15. end
  16. private
  17. def build_shortcode_list
  18. [
  19. {
  20. name: 'gallery',
  21. description: 'Display a gallery of images',
  22. usage: '[gallery ids="1,2,3" columns="3" size="medium"]',
  23. attributes: [
  24. { name: 'ids', description: 'Comma-separated media IDs', required: true },
  25. { name: 'columns', description: 'Number of columns (1-6)', default: '3' },
  26. { name: 'size', description: 'Image size (thumbnail, medium, large)', default: 'medium' }
  27. ],
  28. category: 'Media'
  29. },
  30. {
  31. name: 'button',
  32. description: 'Create a styled button/link',
  33. usage: '[button url="/contact" style="primary" size="medium"]Click Me[/button]',
  34. attributes: [
  35. { name: 'url', description: 'Button URL', required: true },
  36. { name: 'style', description: 'Button style (primary, secondary, success, danger)', default: 'primary' },
  37. { name: 'size', description: 'Button size (small, medium, large)', default: 'medium' },
  38. { name: 'target', description: 'Link target (_self, _blank)', default: '_self' }
  39. ],
  40. category: 'Content'
  41. },
  42. {
  43. name: 'youtube',
  44. description: 'Embed a YouTube video',
  45. usage: '[youtube id="VIDEO_ID" width="560" height="315"]',
  46. attributes: [
  47. { name: 'id', description: 'YouTube video ID', required: true },
  48. { name: 'width', description: 'Video width', default: '560' },
  49. { name: 'height', description: 'Video height', default: '315' }
  50. ],
  51. category: 'Media'
  52. },
  53. {
  54. name: 'recent_posts',
  55. description: 'Display recent posts',
  56. usage: '[recent_posts count="5" category="technology"]',
  57. attributes: [
  58. { name: 'count', description: 'Number of posts to show', default: '5' },
  59. { name: 'category', description: 'Filter by category slug', required: false }
  60. ],
  61. category: 'Content'
  62. },
  63. {
  64. name: 'contact_form',
  65. description: 'Display a contact form',
  66. usage: '[contact_form id="contact" email="admin@example.com"]',
  67. attributes: [
  68. { name: 'id', description: 'Form ID', default: 'contact' },
  69. { name: 'email', description: 'Recipient email', required: false }
  70. ],
  71. category: 'Forms'
  72. },
  73. {
  74. name: 'columns',
  75. description: 'Create column layout',
  76. usage: '[columns count="2"]Content here[/columns]',
  77. attributes: [
  78. { name: 'count', description: 'Number of columns (2-4)', default: '2' }
  79. ],
  80. category: 'Layout'
  81. },
  82. {
  83. name: 'alert',
  84. description: 'Display an alert/notice box',
  85. usage: '[alert type="info"]Your message here[/alert]',
  86. attributes: [
  87. { name: 'type', description: 'Alert type (info, success, warning, danger)', default: 'info' }
  88. ],
  89. category: 'Content'
  90. },
  91. {
  92. name: 'code',
  93. description: 'Display code block with syntax highlighting',
  94. usage: '[code lang="ruby"]puts "Hello World"[/code]',
  95. attributes: [
  96. { name: 'lang', description: 'Programming language', default: 'plaintext' }
  97. ],
  98. category: 'Content'
  99. }
  100. ]
  101. end
  102. end

app/controllers/admin/site_settings_controller.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::SiteSettingsController < Admin::BaseController
  2. before_action :set_site_setting, only: %i[ show edit update destroy ]
  3. # GET /admin/site_settings or /admin/site_settings.json
  4. def index
  5. @site_settings = SiteSetting.all
  6. end
  7. # GET /admin/site_settings/1 or /admin/site_settings/1.json
  8. def show
  9. end
  10. # GET /admin/site_settings/new
  11. def new
  12. @site_setting = SiteSetting.new
  13. end
  14. # GET /admin/site_settings/1/edit
  15. def edit
  16. end
  17. # POST /admin/site_settings or /admin/site_settings.json
  18. def create
  19. @site_setting = SiteSetting.new(site_setting_params)
  20. respond_to do |format|
  21. if @site_setting.save
  22. format.html { redirect_to [:admin, @site_setting], notice: "Site setting was successfully created." }
  23. format.json { render :show, status: :created, location: @site_setting }
  24. else
  25. format.html { render :new, status: :unprocessable_entity }
  26. format.json { render json: @site_setting.errors, status: :unprocessable_entity }
  27. end
  28. end
  29. end
  30. # PATCH/PUT /admin/site_settings/1 or /admin/site_settings/1.json
  31. def update
  32. respond_to do |format|
  33. if @site_setting.update(site_setting_params)
  34. format.html { redirect_to [:admin, @site_setting], notice: "Site setting was successfully updated.", status: :see_other }
  35. format.json { render :show, status: :ok, location: @site_setting }
  36. else
  37. format.html { render :edit, status: :unprocessable_entity }
  38. format.json { render json: @site_setting.errors, status: :unprocessable_entity }
  39. end
  40. end
  41. end
  42. # DELETE /admin/site_settings/1 or /admin/site_settings/1.json
  43. def destroy
  44. @site_setting.destroy!
  45. respond_to do |format|
  46. format.html { redirect_to admin_site_settings_path, notice: "Site setting was successfully destroyed.", status: :see_other }
  47. format.json { head :no_content }
  48. end
  49. end
  50. private
  51. # Use callbacks to share common setup or constraints between actions.
  52. def set_site_setting
  53. @site_setting = SiteSetting.find(params[:id])
  54. end
  55. # Only allow a list of trusted parameters through.
  56. def site_setting_params
  57. params.fetch(:site_setting, {})
  58. end
  59. end

app/controllers/admin/slick_forms/forms_controller.rb

0.0% lines covered

100.0% branches covered

130 relevant lines. 0 lines covered and 130 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms Admin Controller
  2. # Handles form management in the admin panel
  3. class Admin::SlickForms::FormsController < Admin::BaseController
  4. before_action :set_form, only: [:show, :edit, :update, :destroy, :duplicate, :preview]
  5. def index
  6. @forms = get_all_forms
  7. @stats = {
  8. total_forms: @forms.size,
  9. total_submissions: get_submission_count,
  10. active_forms: @forms.count { |f| f[:active] }
  11. }
  12. end
  13. def show
  14. @submissions = get_form_submissions(@form[:id])
  15. @stats = {
  16. total_submissions: @submissions.size,
  17. today_submissions: get_today_submissions(@form[:id]),
  18. conversion_rate: calculate_conversion_rate(@form[:id])
  19. }
  20. end
  21. def new
  22. @form = {
  23. name: '',
  24. title: '',
  25. description: '',
  26. fields: [],
  27. settings: {},
  28. active: true
  29. }
  30. end
  31. def create
  32. form_data = form_params
  33. # Create form record
  34. form_id = create_form_record(form_data)
  35. if form_id
  36. redirect_to admin_slick_forms_form_path(form_id), notice: 'Form was successfully created.'
  37. else
  38. render :new, alert: 'Failed to create form.'
  39. end
  40. end
  41. def edit
  42. # Form data is already loaded in set_form
  43. end
  44. def update
  45. if update_form_record(@form[:id], form_params)
  46. redirect_to admin_slick_forms_form_path(@form[:id]), notice: 'Form was successfully updated.'
  47. else
  48. render :edit, alert: 'Failed to update form.'
  49. end
  50. end
  51. def destroy
  52. if delete_form_record(@form[:id])
  53. redirect_to admin_slick_forms_forms_path, notice: 'Form was successfully deleted.'
  54. else
  55. redirect_to admin_slick_forms_forms_path, alert: 'Failed to delete form.'
  56. end
  57. end
  58. def duplicate
  59. new_form_id = duplicate_form_record(@form[:id])
  60. if new_form_id
  61. redirect_to admin_slick_forms_form_path(new_form_id), notice: 'Form was successfully duplicated.'
  62. else
  63. redirect_to admin_slick_forms_forms_path, alert: 'Failed to duplicate form.'
  64. end
  65. end
  66. def preview
  67. # Render form preview
  68. render layout: 'admin'
  69. end
  70. def import
  71. # Handle form import
  72. redirect_to admin_slick_forms_forms_path, notice: 'Form import feature coming soon.'
  73. end
  74. private
  75. def set_form
  76. @form = get_form_by_id(params[:id])
  77. redirect_to admin_slick_forms_forms_path, alert: 'Form not found.' unless @form
  78. end
  79. def form_params
  80. params.require(:form).permit(:name, :title, :description, :active, fields: [], settings: {})
  81. end
  82. # Private helper methods using ActiveRecord models
  83. def get_all_forms
  84. SlickForm.accessible_by(current_tenant).order(created_at: :desc)
  85. end
  86. def get_form_by_id(id)
  87. SlickForm.accessible_by(current_tenant).find(params[:id])
  88. rescue ActiveRecord::RecordNotFound
  89. nil
  90. end
  91. def create_form_record(data)
  92. form = SlickForm.new(data)
  93. form.tenant = current_tenant if respond_to?(:current_tenant)
  94. form.save ? form.id : nil
  95. end
  96. def update_form_record(id, data)
  97. form = SlickForm.accessible_by(current_tenant).find(id)
  98. form.update(data)
  99. rescue ActiveRecord::RecordNotFound
  100. false
  101. end
  102. def delete_form_record(id)
  103. form = SlickForm.accessible_by(current_tenant).find(id)
  104. form.destroy
  105. true
  106. rescue ActiveRecord::RecordNotFound
  107. false
  108. end
  109. def duplicate_form_record(id)
  110. form = SlickForm.accessible_by(current_tenant).find(id)
  111. new_form = form.dup
  112. new_form.name = "#{form.name} (Copy)"
  113. new_form.title = "#{form.title} (Copy)"
  114. new_form.submissions_count = 0
  115. new_form.save ? new_form.id : nil
  116. rescue ActiveRecord::RecordNotFound
  117. nil
  118. end
  119. def get_form_submissions(form_id)
  120. SlickFormSubmission.where(slick_form_id: form_id)
  121. .accessible_by(current_tenant)
  122. .recent
  123. end
  124. def get_submission_count
  125. SlickFormSubmission.accessible_by(current_tenant).ham.count
  126. end
  127. def get_today_submissions(form_id)
  128. SlickFormSubmission.where(slick_form_id: form_id)
  129. .accessible_by(current_tenant)
  130. .ham
  131. .where('DATE(created_at) = ?', Date.today)
  132. .count
  133. end
  134. def calculate_conversion_rate(form_id)
  135. # This would calculate form views vs submissions
  136. # For now, return a placeholder
  137. 0.0
  138. end
  139. end

app/controllers/admin/slick_forms/submissions_controller.rb

0.0% lines covered

100.0% branches covered

161 relevant lines. 0 lines covered and 161 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms Submissions Controller
  2. # Handles form submission management in the admin panel
  3. class Admin::SlickForms::SubmissionsController < Admin::BaseController
  4. before_action :set_submission, only: [:show, :destroy]
  5. def index
  6. @submissions = get_recent_submissions(50)
  7. @stats = {
  8. total: get_submission_count,
  9. today: get_submissions_today_count,
  10. this_week: get_submissions_week_count
  11. }
  12. # Handle bulk actions
  13. if params[:bulk_action].present? && params[:submission_ids].present?
  14. handle_bulk_action
  15. end
  16. end
  17. def show
  18. @form = get_form_by_id(@submission[:slick_form_id])
  19. end
  20. def destroy
  21. if delete_submission(@submission[:id])
  22. redirect_to admin_slick_forms_submissions_path, notice: 'Submission was successfully deleted.'
  23. else
  24. redirect_to admin_slick_forms_submissions_path, alert: 'Failed to delete submission.'
  25. end
  26. end
  27. def export
  28. submissions = get_all_submissions
  29. csv_data = generate_csv(submissions)
  30. respond_to do |format|
  31. format.csv { send_data csv_data, filename: "slick_forms_submissions_#{Date.today}.csv" }
  32. end
  33. end
  34. def bulk_action
  35. case params[:bulk_action]
  36. when 'delete'
  37. bulk_delete_submissions(params[:submission_ids])
  38. redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were deleted.'
  39. when 'mark_spam'
  40. bulk_mark_spam(params[:submission_ids])
  41. redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were marked as spam.'
  42. when 'mark_ham'
  43. bulk_mark_ham(params[:submission_ids])
  44. redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were marked as legitimate.'
  45. else
  46. redirect_to admin_slick_forms_submissions_path, alert: 'Invalid bulk action.'
  47. end
  48. end
  49. private
  50. def set_submission
  51. @submission = get_submission_by_id(params[:id])
  52. redirect_to admin_slick_forms_submissions_path, alert: 'Submission not found.' unless @submission
  53. end
  54. def handle_bulk_action
  55. case params[:bulk_action]
  56. when 'delete'
  57. bulk_delete_submissions(params[:submission_ids])
  58. flash[:notice] = 'Selected submissions were deleted.'
  59. when 'mark_spam'
  60. bulk_mark_spam(params[:submission_ids])
  61. flash[:notice] = 'Selected submissions were marked as spam.'
  62. when 'mark_ham'
  63. bulk_mark_ham(params[:submission_ids])
  64. flash[:notice] = 'Selected submissions were marked as legitimate.'
  65. end
  66. end
  67. # Database operations
  68. def get_recent_submissions(limit = 50)
  69. return [] unless table_exists?('slick_form_submissions')
  70. ActiveRecord::Base.connection.execute(
  71. "SELECT * FROM slick_form_submissions ORDER BY created_at DESC LIMIT #{limit}"
  72. ).to_a.map(&:symbolize_keys)
  73. end
  74. def get_all_submissions
  75. return [] unless table_exists?('slick_form_submissions')
  76. ActiveRecord::Base.connection.execute(
  77. "SELECT * FROM slick_form_submissions ORDER BY created_at DESC"
  78. ).to_a.map(&:symbolize_keys)
  79. end
  80. def get_submission_by_id(id)
  81. return nil unless table_exists?('slick_form_submissions')
  82. result = ActiveRecord::Base.connection.execute(
  83. "SELECT * FROM slick_form_submissions WHERE id = #{id}"
  84. ).first
  85. result&.symbolize_keys
  86. end
  87. def delete_submission(id)
  88. return false unless table_exists?('slick_form_submissions')
  89. ActiveRecord::Base.connection.execute(
  90. "DELETE FROM slick_form_submissions WHERE id = #{id}"
  91. )
  92. true
  93. end
  94. def bulk_delete_submissions(ids)
  95. return unless table_exists?('slick_form_submissions')
  96. ids = ids.reject(&:blank?)
  97. return if ids.empty?
  98. ActiveRecord::Base.connection.execute(
  99. "DELETE FROM slick_form_submissions WHERE id IN (#{ids.join(',')})"
  100. )
  101. end
  102. def bulk_mark_spam(ids)
  103. return unless table_exists?('slick_form_submissions')
  104. ids = ids.reject(&:blank?)
  105. return if ids.empty?
  106. ActiveRecord::Base.connection.execute(
  107. "UPDATE slick_form_submissions SET spam = 1 WHERE id IN (#{ids.join(',')})"
  108. )
  109. end
  110. def bulk_mark_ham(ids)
  111. return unless table_exists?('slick_form_submissions')
  112. ids = ids.reject(&:blank?)
  113. return if ids.empty?
  114. ActiveRecord::Base.connection.execute(
  115. "UPDATE slick_form_submissions SET spam = 0 WHERE id IN (#{ids.join(',')})"
  116. )
  117. end
  118. def get_submission_count
  119. return 0 unless table_exists?('slick_form_submissions')
  120. ActiveRecord::Base.connection.execute("SELECT COUNT(*) as count FROM slick_form_submissions WHERE spam = 0").first['count']
  121. end
  122. def get_submissions_today_count
  123. return 0 unless table_exists?('slick_form_submissions')
  124. today = Date.today.to_s
  125. ActiveRecord::Base.connection.execute(
  126. "SELECT COUNT(*) as count FROM slick_form_submissions WHERE DATE(created_at) = '#{today}' AND spam = 0"
  127. ).first['count']
  128. end
  129. def get_submissions_week_count
  130. return 0 unless table_exists?('slick_form_submissions')
  131. week_ago = 7.days.ago.to_s
  132. ActiveRecord::Base.connection.execute(
  133. "SELECT COUNT(*) as count FROM slick_form_submissions WHERE created_at >= '#{week_ago}' AND spam = 0"
  134. ).first['count']
  135. end
  136. def get_form_by_id(id)
  137. return nil unless table_exists?('slick_forms')
  138. result = ActiveRecord::Base.connection.execute(
  139. "SELECT * FROM slick_forms WHERE id = #{id}"
  140. ).first
  141. result&.symbolize_keys
  142. end
  143. def generate_csv(submissions)
  144. require 'csv'
  145. CSV.generate do |csv|
  146. # Header
  147. csv << ['ID', 'Form ID', 'Form Name', 'Data', 'IP Address', 'User Agent', 'Spam', 'Created At']
  148. # Data rows
  149. submissions.each do |submission|
  150. form = get_form_by_id(submission[:slick_form_id])
  151. csv << [
  152. submission[:id],
  153. submission[:slick_form_id],
  154. form&.[](:name) || 'Unknown',
  155. submission[:data],
  156. submission[:ip_address],
  157. submission[:user_agent],
  158. submission[:spam] ? 'Yes' : 'No',
  159. submission[:created_at]
  160. ]
  161. end
  162. end
  163. end
  164. def table_exists?(table_name)
  165. ActiveRecord::Base.connection.table_exists?(table_name)
  166. end
  167. end

app/controllers/admin/slick_forms_controller.rb

0.0% lines covered

100.0% branches covered

558 relevant lines. 0 lines covered and 558 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Admin::FluentFormsController
  2. # Admin interface for managing forms, entries, and settings
  3. class Admin::FluentFormsController < Admin::BaseController
  4. before_action :set_form, only: [:edit, :update, :destroy, :duplicate, :toggle_status]
  5. before_action :set_plugin, only: [:index, :new, :create, :settings, :update_settings]
  6. # GET /admin/fluent-forms
  7. def index
  8. @forms = fetch_all_forms
  9. @stats = calculate_stats
  10. end
  11. # GET /admin/fluent-forms/new
  12. def new
  13. @form_templates = form_templates
  14. end
  15. # POST /admin/fluent-forms/create
  16. def create
  17. template_type = params[:template_type] || 'blank'
  18. form_data = {
  19. title: params[:title] || 'Untitled Form',
  20. form_fields: get_template_fields(template_type).to_json,
  21. settings: default_form_settings.to_json,
  22. appearance_settings: default_appearance_settings.to_json,
  23. status: 'draft',
  24. form_type: 'form',
  25. has_payment: false,
  26. conditions: {}.to_json,
  27. created_by: current_user.id
  28. }
  29. ActiveRecord::Base.connection.execute(
  30. "INSERT INTO ff_forms (title, form_fields, settings, appearance_settings, status, form_type,
  31. has_payment, conditions, created_by, created_at, updated_at)
  32. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  33. form_data[:title],
  34. form_data[:form_fields],
  35. form_data[:settings],
  36. form_data[:appearance_settings],
  37. form_data[:status],
  38. form_data[:form_type],
  39. form_data[:has_payment],
  40. form_data[:conditions],
  41. form_data[:created_by],
  42. Time.current,
  43. Time.current
  44. )
  45. form_id = ActiveRecord::Base.connection.last_inserted_row_id
  46. redirect_to edit_admin_fluent_form_path(form_id), notice: 'Form created successfully!'
  47. rescue => e
  48. Rails.logger.error "[Fluent Forms Admin] Create error: #{e.message}"
  49. redirect_to admin_fluent_forms_path, alert: 'Failed to create form'
  50. end
  51. # GET /admin/fluent-forms/:id/edit
  52. def edit
  53. @form = @form_data
  54. @field_types = field_types
  55. @integrations = available_integrations
  56. end
  57. # PATCH /admin/fluent-forms/:id
  58. def update
  59. update_params = {
  60. title: params[:title],
  61. form_fields: params[:form_fields],
  62. settings: params[:settings],
  63. appearance_settings: params[:appearance_settings],
  64. status: params[:status],
  65. has_payment: params[:has_payment],
  66. conditions: params[:conditions]
  67. }
  68. sql_parts = []
  69. sql_values = []
  70. update_params.each do |key, value|
  71. next if value.nil?
  72. sql_parts << "#{key} = ?"
  73. sql_values << value
  74. end
  75. sql_values << Time.current
  76. sql_parts << "updated_at = ?"
  77. sql_values << params[:id]
  78. ActiveRecord::Base.connection.execute(
  79. "UPDATE ff_forms SET #{sql_parts.join(', ')} WHERE id = ?",
  80. *sql_values
  81. )
  82. if request.xhr?
  83. render json: { success: true, message: 'Form updated successfully!' }
  84. else
  85. redirect_to edit_admin_fluent_form_path(params[:id]), notice: 'Form updated successfully!'
  86. end
  87. rescue => e
  88. Rails.logger.error "[Fluent Forms Admin] Update error: #{e.message}"
  89. if request.xhr?
  90. render json: { success: false, message: 'Failed to update form' }, status: 422
  91. else
  92. redirect_to edit_admin_fluent_form_path(params[:id]), alert: 'Failed to update form'
  93. end
  94. end
  95. # DELETE /admin/fluent-forms/:id
  96. def destroy
  97. ActiveRecord::Base.connection.execute("DELETE FROM ff_forms WHERE id = ?", params[:id])
  98. redirect_to admin_fluent_forms_path, notice: 'Form deleted successfully!'
  99. rescue => e
  100. Rails.logger.error "[Fluent Forms Admin] Delete error: #{e.message}"
  101. redirect_to admin_fluent_forms_path, alert: 'Failed to delete form'
  102. end
  103. # POST /admin/fluent-forms/:id/duplicate
  104. def duplicate
  105. new_title = "#{@form_data[:title]} (Copy)"
  106. ActiveRecord::Base.connection.execute(
  107. "INSERT INTO ff_forms (title, form_fields, settings, appearance_settings, status, form_type,
  108. has_payment, conditions, created_by, created_at, updated_at)
  109. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  110. new_title,
  111. @form_data[:form_fields].to_json,
  112. @form_data[:settings].to_json,
  113. @form_data[:appearance_settings].to_json,
  114. 'draft',
  115. @form_data[:form_type],
  116. @form_data[:has_payment],
  117. @form_data[:conditions].to_json,
  118. current_user.id,
  119. Time.current,
  120. Time.current
  121. )
  122. redirect_to admin_fluent_forms_path, notice: 'Form duplicated successfully!'
  123. rescue => e
  124. Rails.logger.error "[Fluent Forms Admin] Duplicate error: #{e.message}"
  125. redirect_to admin_fluent_forms_path, alert: 'Failed to duplicate form'
  126. end
  127. # POST /admin/fluent-forms/:id/toggle-status
  128. def toggle_status
  129. new_status = @form_data[:status] == 'published' ? 'draft' : 'published'
  130. ActiveRecord::Base.connection.execute(
  131. "UPDATE ff_forms SET status = ?, updated_at = ? WHERE id = ?",
  132. new_status,
  133. Time.current,
  134. params[:id]
  135. )
  136. render json: { success: true, status: new_status }
  137. rescue => e
  138. render json: { success: false, message: e.message }, status: 422
  139. end
  140. # GET /admin/fluent-forms/entries
  141. def entries
  142. @form_id = params[:form_id]
  143. @entries = fetch_entries(@form_id)
  144. @forms = fetch_all_forms
  145. @filters = {
  146. status: params[:status],
  147. date_range: params[:date_range],
  148. search: params[:search]
  149. }
  150. end
  151. # GET /admin/fluent-forms/entries/:id
  152. def entry_details
  153. @entry = fetch_entry_details(params[:id])
  154. @form = fetch_form(@entry[:form_id])
  155. end
  156. # POST /admin/fluent-forms/entries/:id/mark-read
  157. def mark_entry_read
  158. update_entry_status(params[:id], 'read')
  159. render json: { success: true }
  160. end
  161. # POST /admin/fluent-forms/entries/:id/favorite
  162. def toggle_favorite
  163. entry = fetch_entry_details(params[:id])
  164. new_favorite = !entry[:is_favorite]
  165. ActiveRecord::Base.connection.execute(
  166. "UPDATE ff_submissions SET is_favorite = ?, updated_at = ? WHERE id = ?",
  167. new_favorite,
  168. Time.current,
  169. params[:id]
  170. )
  171. render json: { success: true, is_favorite: new_favorite }
  172. end
  173. # DELETE /admin/fluent-forms/entries/:id
  174. def delete_entry
  175. ActiveRecord::Base.connection.execute("DELETE FROM ff_submissions WHERE id = ?", params[:id])
  176. redirect_to admin_fluent_forms_entries_path, notice: 'Entry deleted successfully!'
  177. end
  178. # GET /admin/fluent-forms/entries/export
  179. def export_entries
  180. form_id = params[:form_id]
  181. format = params[:format] || 'csv'
  182. entries = fetch_entries(form_id, limit: nil)
  183. case format
  184. when 'csv'
  185. send_data generate_csv(entries), filename: "entries-#{form_id}-#{Time.current.to_i}.csv"
  186. when 'json'
  187. send_data entries.to_json, filename: "entries-#{form_id}-#{Time.current.to_i}.json"
  188. else
  189. redirect_to admin_fluent_forms_entries_path, alert: 'Invalid export format'
  190. end
  191. end
  192. # GET /admin/fluent-forms/analytics
  193. def analytics
  194. @form_id = params[:form_id]
  195. @date_range = params[:date_range] || '30_days'
  196. @analytics_data = calculate_analytics(@form_id, @date_range)
  197. @forms = fetch_all_forms
  198. end
  199. # GET /admin/fluent-forms/integrations
  200. def integrations
  201. @integrations = available_integrations
  202. @active_integrations = get_active_integrations
  203. end
  204. # POST /admin/fluent-forms/integrations/:integration/toggle
  205. def toggle_integration
  206. integration_name = params[:integration]
  207. # Toggle integration logic here
  208. render json: { success: true }
  209. end
  210. # GET /admin/fluent-forms/settings
  211. def settings
  212. @settings = @plugin.get_all_settings
  213. @tabs = ['general', 'email', 'payments', 'spam_protection', 'file_uploads', 'integrations']
  214. end
  215. # PATCH /admin/fluent-forms/settings
  216. def update_settings
  217. settings_params = params.require(:settings).permit!
  218. settings_params.each do |key, value|
  219. @plugin.set_setting(key, value)
  220. end
  221. redirect_to admin_fluent_forms_settings_path, notice: 'Settings updated successfully!'
  222. rescue => e
  223. Rails.logger.error "[Fluent Forms Admin] Settings update error: #{e.message}"
  224. redirect_to admin_fluent_forms_settings_path, alert: 'Failed to update settings'
  225. end
  226. private
  227. def set_form
  228. @form_data = fetch_form(params[:id])
  229. redirect_to admin_fluent_forms_path, alert: 'Form not found' unless @form_data
  230. end
  231. def set_plugin
  232. @plugin = FluentFormsPro.new
  233. end
  234. def fetch_all_forms
  235. results = ActiveRecord::Base.connection.execute("SELECT * FROM ff_forms ORDER BY created_at DESC")
  236. results.map do |row|
  237. {
  238. id: row[0],
  239. title: row[1],
  240. status: row[4],
  241. form_type: row[6],
  242. has_payment: row[7],
  243. created_at: row[10],
  244. updated_at: row[11],
  245. submission_count: count_submissions(row[0])
  246. }
  247. end
  248. rescue => e
  249. Rails.logger.error "[Fluent Forms Admin] Fetch forms error: #{e.message}"
  250. []
  251. end
  252. def fetch_form(form_id)
  253. result = ActiveRecord::Base.connection.execute(
  254. "SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
  255. form_id
  256. ).first
  257. return nil unless result
  258. {
  259. id: result[0],
  260. title: result[1],
  261. form_fields: JSON.parse(result[2] || '{}'),
  262. settings: JSON.parse(result[3] || '{}'),
  263. status: result[4],
  264. appearance_settings: result[5] ? JSON.parse(result[5]) : {},
  265. form_type: result[6],
  266. has_payment: result[7],
  267. conditions: result[8] ? JSON.parse(result[8]) : {},
  268. created_at: result[10],
  269. updated_at: result[11]
  270. }
  271. rescue => e
  272. Rails.logger.error "[Fluent Forms Admin] Fetch form error: #{e.message}"
  273. nil
  274. end
  275. def fetch_entries(form_id, options = {})
  276. limit = options[:limit] || 50
  277. query = if form_id
  278. "SELECT * FROM ff_submissions WHERE form_id = ? ORDER BY created_at DESC"
  279. else
  280. "SELECT * FROM ff_submissions ORDER BY created_at DESC"
  281. end
  282. query += " LIMIT #{limit}" if limit
  283. results = if form_id
  284. ActiveRecord::Base.connection.execute(query, form_id)
  285. else
  286. ActiveRecord::Base.connection.execute(query)
  287. end
  288. results.map do |row|
  289. {
  290. id: row[0],
  291. form_id: row[1],
  292. serial_number: row[2],
  293. response_data: JSON.parse(row[3] || '{}'),
  294. source_url: row[4],
  295. user_id: row[5],
  296. status: row[12],
  297. is_favorite: row[13],
  298. created_at: row[14]
  299. }
  300. end
  301. rescue => e
  302. Rails.logger.error "[Fluent Forms Admin] Fetch entries error: #{e.message}"
  303. []
  304. end
  305. def fetch_entry_details(entry_id)
  306. result = ActiveRecord::Base.connection.execute(
  307. "SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
  308. entry_id
  309. ).first
  310. return nil unless result
  311. {
  312. id: result[0],
  313. form_id: result[1],
  314. serial_number: result[2],
  315. response_data: JSON.parse(result[3] || '{}'),
  316. source_url: result[4],
  317. user_id: result[5],
  318. browser: result[6],
  319. device: result[7],
  320. ip_address: result[8],
  321. city: result[9],
  322. country: result[10],
  323. payment_status: result[11],
  324. status: result[12],
  325. is_favorite: result[13],
  326. created_at: result[14],
  327. updated_at: result[15]
  328. }
  329. end
  330. def count_submissions(form_id)
  331. result = ActiveRecord::Base.connection.execute(
  332. "SELECT COUNT(*) FROM ff_submissions WHERE form_id = ?",
  333. form_id
  334. ).first
  335. result.first
  336. rescue
  337. 0
  338. end
  339. def update_entry_status(entry_id, status)
  340. ActiveRecord::Base.connection.execute(
  341. "UPDATE ff_submissions SET status = ?, updated_at = ? WHERE id = ?",
  342. status,
  343. Time.current,
  344. entry_id
  345. )
  346. end
  347. def calculate_stats
  348. {
  349. total_forms: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_forms").first.first,
  350. total_submissions: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_submissions").first.first,
  351. unread_submissions: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_submissions WHERE status = 'unread'").first.first,
  352. forms_with_payments: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_forms WHERE has_payment = 1").first.first
  353. }
  354. rescue
  355. { total_forms: 0, total_submissions: 0, unread_submissions: 0, forms_with_payments: 0 }
  356. end
  357. def calculate_analytics(form_id, date_range)
  358. # Analytics calculation logic
  359. {
  360. views: rand(100..1000),
  361. submissions: count_submissions(form_id),
  362. conversion_rate: rand(10..50),
  363. average_completion_time: rand(30..180)
  364. }
  365. end
  366. def generate_csv(entries)
  367. require 'csv'
  368. CSV.generate do |csv|
  369. # Header row
  370. if entries.any?
  371. headers = ['ID', 'Serial Number', 'Status', 'Created At']
  372. headers += entries.first[:response_data].keys
  373. csv << headers
  374. # Data rows
  375. entries.each do |entry|
  376. row = [
  377. entry[:id],
  378. entry[:serial_number],
  379. entry[:status],
  380. entry[:created_at]
  381. ]
  382. row += entry[:response_data].values
  383. csv << row
  384. end
  385. end
  386. end
  387. end
  388. def form_templates
  389. [
  390. { id: 'blank', name: 'Blank Form', description: 'Start from scratch' },
  391. { id: 'contact', name: 'Contact Form', description: 'Simple contact form with name, email, and message' },
  392. { id: 'registration', name: 'Registration Form', description: 'User registration with multiple fields' },
  393. { id: 'survey', name: 'Survey Form', description: 'Survey with multiple choice questions' },
  394. { id: 'order', name: 'Order Form', description: 'Product order form with payment' },
  395. { id: 'booking', name: 'Booking Form', description: 'Appointment booking form' },
  396. { id: 'feedback', name: 'Feedback Form', description: 'Customer feedback form' },
  397. { id: 'application', name: 'Application Form', description: 'Job or program application' },
  398. { id: 'newsletter', name: 'Newsletter Signup', description: 'Simple email capture form' },
  399. { id: 'quiz', name: 'Quiz Form', description: 'Quiz with scoring' }
  400. ]
  401. end
  402. def get_template_fields(template_type)
  403. case template_type
  404. when 'contact'
  405. contact_form_template
  406. when 'registration'
  407. registration_form_template
  408. when 'survey'
  409. survey_form_template
  410. else
  411. blank_form_template
  412. end
  413. end
  414. def blank_form_template
  415. {
  416. fields: [],
  417. submitButton: default_submit_button
  418. }
  419. end
  420. def contact_form_template
  421. {
  422. fields: [
  423. text_field('name', 'Name', true),
  424. email_field('email', 'Email', true),
  425. textarea_field('message', 'Message', true)
  426. ],
  427. submitButton: default_submit_button
  428. }
  429. end
  430. def registration_form_template
  431. {
  432. fields: [
  433. text_field('first_name', 'First Name', true),
  434. text_field('last_name', 'Last Name', true),
  435. email_field('email', 'Email', true),
  436. text_field('phone', 'Phone Number', false),
  437. textarea_field('address', 'Address', false)
  438. ],
  439. submitButton: default_submit_button
  440. }
  441. end
  442. def survey_form_template
  443. {
  444. fields: [
  445. text_field('name', 'Your Name', true),
  446. radio_field('satisfaction', 'How satisfied are you?', ['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied'], true),
  447. textarea_field('comments', 'Additional Comments', false)
  448. ],
  449. submitButton: default_submit_button
  450. }
  451. end
  452. def text_field(name, label, required)
  453. {
  454. index: rand(1000),
  455. element: 'input_text',
  456. attributes: { name: name, 'data-required': required, 'data-type': 'text' },
  457. settings: {
  458. label: label,
  459. label_placement: 'top',
  460. admin_field_label: label,
  461. validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
  462. }
  463. }
  464. end
  465. def email_field(name, label, required)
  466. {
  467. index: rand(1000),
  468. element: 'input_email',
  469. attributes: { name: name, 'data-required': required, 'data-type': 'email' },
  470. settings: {
  471. label: label,
  472. label_placement: 'top',
  473. admin_field_label: label,
  474. validation_rules: {
  475. required: { value: true, message: "#{label} is required" },
  476. email: { value: true, message: 'Please enter a valid email' }
  477. }
  478. }
  479. }
  480. end
  481. def textarea_field(name, label, required)
  482. {
  483. index: rand(1000),
  484. element: 'textarea',
  485. attributes: { name: name, 'data-required': required, 'data-type': 'text', rows: 4 },
  486. settings: {
  487. label: label,
  488. label_placement: 'top',
  489. admin_field_label: label,
  490. validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
  491. }
  492. }
  493. end
  494. def radio_field(name, label, options, required)
  495. {
  496. index: rand(1000),
  497. element: 'input_radio',
  498. attributes: { name: name, 'data-required': required, 'data-type': 'radio' },
  499. settings: {
  500. label: label,
  501. label_placement: 'top',
  502. admin_field_label: label,
  503. options: options.map { |opt| { label: opt, value: opt.parameterize } },
  504. validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
  505. }
  506. }
  507. end
  508. def default_submit_button
  509. {
  510. uniqElKey: 'el_submit',
  511. element: 'button',
  512. attributes: { type: 'submit', class: 'ff-btn ff-btn-submit ff-btn-md' },
  513. settings: {
  514. align: 'left',
  515. button_style: 'default',
  516. button_size: 'md',
  517. background_color: '#409EFF',
  518. color: '#ffffff',
  519. button_ui: { type: 'default', text: 'Submit', img_url: '' }
  520. }
  521. }
  522. end
  523. def default_form_settings
  524. {
  525. confirmation: {
  526. redirectTo: 'samePage',
  527. messageToShow: 'Thank you for your submission!',
  528. samePageFormBehavior: 'hide_form'
  529. },
  530. restrictions: {},
  531. layout: {
  532. labelPlacement: 'top',
  533. helpMessagePlacement: 'with_label',
  534. errorMessagePlacement: 'inline'
  535. }
  536. }
  537. end
  538. def default_appearance_settings
  539. {
  540. theme: 'default',
  541. customCss: '',
  542. submitButtonPosition: 'left'
  543. }
  544. end
  545. def field_types
  546. [
  547. { type: 'input_text', label: 'Text Input', icon: 'text' },
  548. { type: 'input_email', label: 'Email', icon: 'envelope' },
  549. { type: 'input_number', label: 'Number', icon: 'hashtag' },
  550. { type: 'input_phone', label: 'Phone', icon: 'phone' },
  551. { type: 'textarea', label: 'Textarea', icon: 'align-left' },
  552. { type: 'select', label: 'Dropdown', icon: 'caret-down' },
  553. { type: 'input_radio', label: 'Radio Button', icon: 'dot-circle' },
  554. { type: 'input_checkbox', label: 'Checkbox', icon: 'check-square' },
  555. { type: 'input_date', label: 'Date', icon: 'calendar' },
  556. { type: 'input_file', label: 'File Upload', icon: 'upload' },
  557. { type: 'input_hidden', label: 'Hidden Field', icon: 'eye-slash' },
  558. { type: 'input_password', label: 'Password', icon: 'lock' },
  559. { type: 'input_url', label: 'Website URL', icon: 'link' },
  560. { type: 'rating', label: 'Rating', icon: 'star' },
  561. { type: 'slider', label: 'Slider', icon: 'sliders-h' },
  562. { type: 'repeater', label: 'Repeater', icon: 'redo' },
  563. { type: 'step', label: 'Step', icon: 'shoe-prints' },
  564. { type: 'html', label: 'HTML', icon: 'code' },
  565. { type: 'section_break', label: 'Section Break', icon: 'minus' },
  566. { type: 'payment', label: 'Payment', icon: 'credit-card' }
  567. ]
  568. end
  569. def available_integrations
  570. [
  571. { id: 'mailchimp', name: 'Mailchimp', description: 'Email marketing', icon: 'mailchimp' },
  572. { id: 'slack', name: 'Slack', description: 'Team messaging', icon: 'slack' },
  573. { id: 'zapier', name: 'Zapier', description: 'Connect to 3000+ apps', icon: 'zapier' },
  574. { id: 'webhook', name: 'Webhooks', description: 'Custom webhooks', icon: 'link' },
  575. { id: 'google_sheets', name: 'Google Sheets', description: 'Spreadsheet integration', icon: 'table' },
  576. { id: 'stripe', name: 'Stripe', description: 'Payment processing', icon: 'stripe' },
  577. { id: 'paypal', name: 'PayPal', description: 'Payment processing', icon: 'paypal' }
  578. ]
  579. end
  580. def get_active_integrations
  581. # Return list of active integrations
  582. []
  583. end
  584. end

app/controllers/admin/storage_providers_controller.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::StorageProvidersController < Admin::BaseController
  2. before_action :set_storage_provider, only: %i[show edit update destroy toggle]
  3. # GET /admin/storage_providers
  4. def index
  5. @storage_providers = StorageProvider.ordered
  6. end
  7. # GET /admin/storage_providers/1
  8. def show
  9. end
  10. # GET /admin/storage_providers/new
  11. def new
  12. @storage_provider = StorageProvider.new
  13. end
  14. # GET /admin/storage_providers/1/edit
  15. def edit
  16. end
  17. # POST /admin/storage_providers
  18. def create
  19. @storage_provider = StorageProvider.new(storage_provider_params)
  20. respond_to do |format|
  21. if @storage_provider.save
  22. format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully created." }
  23. format.json { render :show, status: :created, location: @storage_provider }
  24. else
  25. format.html { render :new, status: :unprocessable_entity }
  26. format.json { render json: @storage_provider.errors, status: :unprocessable_entity }
  27. end
  28. end
  29. end
  30. # PATCH/PUT /admin/storage_providers/1
  31. def update
  32. respond_to do |format|
  33. if @storage_provider.update(storage_provider_params)
  34. format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully updated." }
  35. format.json { render :show, status: :ok, location: @storage_provider }
  36. else
  37. format.html { render :edit, status: :unprocessable_entity }
  38. format.json { render json: @storage_provider.errors, status: :unprocessable_entity }
  39. end
  40. end
  41. end
  42. # DELETE /admin/storage_providers/1
  43. def destroy
  44. @storage_provider.destroy!
  45. respond_to do |format|
  46. format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully destroyed." }
  47. format.json { head :no_content }
  48. end
  49. end
  50. # PATCH /admin/storage_providers/1/toggle
  51. def toggle
  52. @storage_provider.update!(active: !@storage_provider.active)
  53. respond_to do |format|
  54. format.html { redirect_to admin_storage_providers_path, notice: "Storage provider status updated." }
  55. format.json { render json: { active: @storage_provider.active } }
  56. end
  57. end
  58. private
  59. def set_storage_provider
  60. @storage_provider = StorageProvider.find(params[:id])
  61. end
  62. def storage_provider_params
  63. params.require(:storage_provider).permit(
  64. :name,
  65. :provider_type,
  66. :active,
  67. :position,
  68. config: [
  69. :local_path,
  70. :access_key_id,
  71. :secret_access_key,
  72. :region,
  73. :bucket,
  74. :endpoint,
  75. :project,
  76. :credentials,
  77. :storage_account_name,
  78. :storage_access_key,
  79. :container
  80. ]
  81. )
  82. end
  83. end

app/controllers/admin/subscribers_controller.rb

0.0% lines covered

100.0% branches covered

144 relevant lines. 0 lines covered and 144 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::SubscribersController < Admin::BaseController
  2. before_action :set_subscriber, only: [:show, :edit, :update, :destroy, :confirm, :unsubscribe]
  3. # GET /admin/subscribers
  4. def index
  5. @subscribers = Subscriber.includes(:versions).recent
  6. # Filter by status
  7. @subscribers = @subscribers.where(status: params[:status]) if params[:status].present?
  8. # Filter by source
  9. @subscribers = @subscribers.by_source(params[:source]) if params[:source].present?
  10. # Filter by tag
  11. @subscribers = @subscribers.by_tag(params[:tag]) if params[:tag].present?
  12. # Filter by list
  13. @subscribers = @subscribers.by_list(params[:list]) if params[:list].present?
  14. # Search
  15. @subscribers = @subscribers.search(params[:q]) if params[:q].present?
  16. # Get stats
  17. @stats = Subscriber.stats
  18. # For Tabulator (AJAX)
  19. respond_to do |format|
  20. format.html
  21. format.json do
  22. render json: {
  23. data: @subscribers.limit(params[:size] || 20).offset(params[:page].to_i * (params[:size] || 20).to_i).map { |s| subscriber_json(s) },
  24. last_page: @subscribers.count / (params[:size] || 20).to_i
  25. }
  26. end
  27. end
  28. end
  29. # GET /admin/subscribers/:id
  30. def show
  31. end
  32. # GET /admin/subscribers/new
  33. def new
  34. @subscriber = Subscriber.new
  35. end
  36. # GET /admin/subscribers/:id/edit
  37. def edit
  38. end
  39. # POST /admin/subscribers
  40. def create
  41. @subscriber = Subscriber.new(subscriber_params)
  42. @subscriber.status = 'confirmed' # Manual adds are auto-confirmed
  43. @subscriber.confirmed_at = Time.current
  44. @subscriber.source = 'admin'
  45. if @subscriber.save
  46. redirect_to admin_subscribers_path, notice: 'Subscriber added successfully.'
  47. else
  48. render :new, status: :unprocessable_entity
  49. end
  50. end
  51. # PATCH/PUT /admin/subscribers/:id
  52. def update
  53. if @subscriber.update(subscriber_params)
  54. redirect_to admin_subscribers_path, notice: 'Subscriber updated successfully.'
  55. else
  56. render :edit, status: :unprocessable_entity
  57. end
  58. end
  59. # DELETE /admin/subscribers/:id
  60. def destroy
  61. @subscriber.destroy
  62. redirect_to admin_subscribers_path, notice: 'Subscriber deleted successfully.'
  63. end
  64. # PATCH /admin/subscribers/:id/confirm
  65. def confirm
  66. @subscriber.confirm!
  67. redirect_to admin_subscribers_path, notice: 'Subscriber confirmed.'
  68. end
  69. # PATCH /admin/subscribers/:id/unsubscribe
  70. def unsubscribe
  71. @subscriber.unsubscribe!
  72. redirect_to admin_subscribers_path, notice: 'Subscriber unsubscribed.'
  73. end
  74. # POST /admin/subscribers/bulk_action
  75. def bulk_action
  76. subscriber_ids = params[:subscriber_ids] || []
  77. action = params[:bulk_action]
  78. case action
  79. when 'confirm'
  80. Subscriber.where(id: subscriber_ids).each(&:confirm!)
  81. message = "#{subscriber_ids.count} subscribers confirmed."
  82. when 'unsubscribe'
  83. Subscriber.where(id: subscriber_ids).each(&:unsubscribe!)
  84. message = "#{subscriber_ids.count} subscribers unsubscribed."
  85. when 'delete'
  86. Subscriber.where(id: subscriber_ids).destroy_all
  87. message = "#{subscriber_ids.count} subscribers deleted."
  88. when 'add_tag'
  89. tag = params[:tag_value]
  90. Subscriber.where(id: subscriber_ids).each { |s| s.add_tag(tag) }
  91. message = "Tag '#{tag}' added to #{subscriber_ids.count} subscribers."
  92. when 'add_to_list'
  93. list = params[:list_value]
  94. Subscriber.where(id: subscriber_ids).each { |s| s.add_to_list(list) }
  95. message = "#{subscriber_ids.count} subscribers added to list '#{list}'."
  96. else
  97. message = "Invalid action."
  98. end
  99. redirect_to admin_subscribers_path, notice: message
  100. end
  101. # GET /admin/subscribers/import
  102. def import
  103. end
  104. # POST /admin/subscribers/do_import
  105. def do_import
  106. unless params[:file].present?
  107. redirect_to import_admin_subscribers_path, alert: 'Please select a file to import.'
  108. return
  109. end
  110. file = params[:file]
  111. begin
  112. result = Subscriber.import_from_csv(file.read)
  113. if result[:errors].empty?
  114. redirect_to admin_subscribers_path, notice: "Successfully imported #{result[:imported]} subscribers."
  115. else
  116. flash[:alert] = "Imported #{result[:imported]} of #{result[:total]} subscribers. #{result[:errors].count} errors occurred."
  117. redirect_to admin_subscribers_path
  118. end
  119. rescue => e
  120. redirect_to import_admin_subscribers_path, alert: "Import failed: #{e.message}"
  121. end
  122. end
  123. # GET /admin/subscribers/export
  124. def export
  125. csv_data = Subscriber.to_csv
  126. send_data csv_data,
  127. filename: "subscribers-#{Date.today}.csv",
  128. type: 'text/csv',
  129. disposition: 'attachment'
  130. end
  131. # GET /admin/subscribers/stats
  132. def stats
  133. render json: Subscriber.stats
  134. end
  135. private
  136. def set_subscriber
  137. @subscriber = Subscriber.find(params[:id])
  138. end
  139. def subscriber_params
  140. params.require(:subscriber).permit(
  141. :email,
  142. :name,
  143. :status,
  144. :source,
  145. :notes,
  146. tags: [],
  147. lists: [],
  148. metadata: {}
  149. )
  150. end
  151. def subscriber_json(subscriber)
  152. {
  153. id: subscriber.id,
  154. email: subscriber.email,
  155. name: subscriber.name,
  156. status: subscriber.status,
  157. source: subscriber.source,
  158. tags: subscriber.tags || [],
  159. lists: subscriber.lists || [],
  160. confirmed_at: subscriber.confirmed_at&.strftime('%Y-%m-%d %H:%M'),
  161. created_at: subscriber.created_at.strftime('%Y-%m-%d %H:%M'),
  162. actions: view_context.render(partial: 'admin/subscribers/actions', locals: { subscriber: subscriber })
  163. }
  164. end
  165. end

app/controllers/admin/system/api_tokens_controller.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::System::ApiTokensController < Admin::BaseController
  2. before_action :set_api_token, only: [:show, :edit, :update, :destroy, :toggle, :regenerate]
  3. def index
  4. @api_tokens = current_user.administrator? ? ApiToken.all.includes(:user) : current_user.api_tokens
  5. @api_tokens = @api_tokens.recent.page(params[:page]).per(20)
  6. end
  7. def show
  8. end
  9. def new
  10. @api_token = current_user.api_tokens.build(role: 'public')
  11. end
  12. def create
  13. @api_token = current_user.api_tokens.build(api_token_params)
  14. if @api_token.save
  15. flash[:notice] = "API Token created successfully. Token: #{@api_token.token}"
  16. redirect_to admin_system_api_token_path(@api_token)
  17. else
  18. render :new, status: :unprocessable_entity
  19. end
  20. end
  21. def edit
  22. end
  23. def update
  24. if @api_token.update(api_token_params)
  25. flash[:notice] = "API Token updated successfully."
  26. redirect_to admin_system_api_token_path(@api_token)
  27. else
  28. render :edit, status: :unprocessable_entity
  29. end
  30. end
  31. def destroy
  32. @api_token.destroy
  33. flash[:notice] = "API Token deleted successfully."
  34. redirect_to admin_system_api_tokens_path
  35. end
  36. def toggle
  37. @api_token.update!(active: !@api_token.active)
  38. flash[:notice] = "API Token #{@api_token.active ? 'activated' : 'deactivated'}."
  39. redirect_to admin_system_api_tokens_path
  40. end
  41. def regenerate
  42. new_token = SecureRandom.base58(32)
  43. @api_token.update!(token: new_token)
  44. flash[:notice] = "API Token regenerated. New token: #{new_token}"
  45. redirect_to admin_system_api_token_path(@api_token)
  46. end
  47. private
  48. def set_api_token
  49. @api_token = if current_user.administrator?
  50. ApiToken.find(params[:id])
  51. else
  52. current_user.api_tokens.find(params[:id])
  53. end
  54. end
  55. def api_token_params
  56. permitted = [:name, :role, :expires_at, :active]
  57. # Only admins can set custom permissions
  58. permitted << :permissions if current_user.administrator?
  59. params.require(:api_token).permit(permitted)
  60. end
  61. end

app/controllers/admin/system/channel_overrides_controller.rb

0.0% lines covered

100.0% branches covered

107 relevant lines. 0 lines covered and 107 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Admin
  2. module System
  3. class ChannelOverridesController < Admin::BaseController
  4. before_action :set_channel
  5. before_action :set_channel_override, only: [:show, :edit, :update, :destroy]
  6. def index
  7. @overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
  8. @overrides_by_type = @overrides.group_by(&:resource_type)
  9. end
  10. def show
  11. end
  12. def new
  13. @channel_override = @channel.channel_overrides.build
  14. @resource_types = %w[Post Page Medium Setting]
  15. end
  16. def create
  17. @channel_override = @channel.channel_overrides.build(channel_override_params)
  18. if @channel_override.save
  19. redirect_to admin_system_channel_channel_overrides_path(@channel), notice: 'Override was successfully created.'
  20. else
  21. @resource_types = %w[Post Page Medium Setting]
  22. render :new
  23. end
  24. end
  25. def edit
  26. @resource_types = %w[Post Page Medium Setting]
  27. end
  28. def update
  29. if @channel_override.update(channel_override_params)
  30. redirect_to admin_system_channel_channel_overrides_path(@channel), notice: 'Override was successfully updated.'
  31. else
  32. @resource_types = %w[Post Page Medium Setting]
  33. render :edit
  34. end
  35. end
  36. def destroy
  37. @channel_override.destroy
  38. redirect_to admin_system_channel_channel_overrides_path(@channel), notice: 'Override was successfully deleted.'
  39. end
  40. def copy_from_channel
  41. source_channel = Channel.find(params[:source_channel_id])
  42. source_channel.channel_overrides.each do |override|
  43. new_override = override.dup
  44. new_override.channel = @channel
  45. new_override.save
  46. end
  47. redirect_to admin_system_channel_channel_overrides_path(@channel), notice: "Overrides copied from #{source_channel.name}."
  48. end
  49. def export
  50. overrides_data = @channel.channel_overrides.map do |override|
  51. {
  52. resource_type: override.resource_type,
  53. resource_id: override.resource_id,
  54. kind: override.kind,
  55. path: override.path,
  56. data: override.data,
  57. enabled: override.enabled
  58. }
  59. end
  60. respond_to do |format|
  61. format.json { render json: { channel: @channel.name, overrides: overrides_data } }
  62. format.yaml { render plain: overrides_data.to_yaml }
  63. end
  64. end
  65. def import
  66. if params[:file].present?
  67. begin
  68. data = case File.extname(params[:file].original_filename)
  69. when '.json'
  70. JSON.parse(params[:file].read)
  71. when '.yml', '.yaml'
  72. YAML.load(params[:file].read)
  73. else
  74. raise "Unsupported file format"
  75. end
  76. overrides_data = data.is_a?(Hash) && data['overrides'] ? data['overrides'] : data
  77. overrides_data.each do |override_data|
  78. @channel.channel_overrides.create!(
  79. resource_type: override_data['resource_type'],
  80. resource_id: override_data['resource_id'],
  81. kind: override_data['kind'],
  82. path: override_data['path'],
  83. data: override_data['data'],
  84. enabled: override_data['enabled']
  85. )
  86. end
  87. redirect_to admin_system_channel_channel_overrides_path(@channel), notice: 'Overrides imported successfully.'
  88. rescue => e
  89. redirect_to admin_system_channel_channel_overrides_path(@channel), alert: "Import failed: #{e.message}"
  90. end
  91. else
  92. redirect_to admin_system_channel_channel_overrides_path(@channel), alert: 'No file provided.'
  93. end
  94. end
  95. private
  96. def set_channel
  97. @channel = Channel.find(params[:channel_id])
  98. end
  99. def set_channel_override
  100. @channel_override = @channel.channel_overrides.find(params[:id])
  101. end
  102. def channel_override_params
  103. params.require(:channel_override).permit(:resource_type, :resource_id, :kind, :path, :enabled, data: {})
  104. end
  105. end
  106. end
  107. end

app/controllers/admin/system/channels_controller.rb

0.0% lines covered

100.0% branches covered

165 relevant lines. 0 lines covered and 165 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Admin
  2. module System
  3. class ChannelsController < Admin::BaseController
  4. before_action :set_channel, only: [:show, :edit, :update, :destroy]
  5. def index
  6. @channels = Channel.all.order(:name)
  7. respond_to do |format|
  8. format.html do
  9. @channels_data = channels_json
  10. @stats = {
  11. total: Channel.count,
  12. active: Channel.active.count,
  13. overrides: ChannelOverride.count,
  14. content_items: Post.count + Page.count + Medium.count
  15. }
  16. @bulk_actions = [
  17. { value: 'enable', label: 'Enable Channels' },
  18. { value: 'disable', label: 'Disable Channels' },
  19. { value: 'delete', label: 'Delete Channels' }
  20. ]
  21. @status_options = [
  22. { value: 'enabled', label: 'Enabled' },
  23. { value: 'disabled', label: 'Disabled' }
  24. ]
  25. @columns = [
  26. {
  27. title: "",
  28. formatter: "rowSelection",
  29. titleFormatter: "rowSelection",
  30. width: 40,
  31. headerSort: false
  32. },
  33. {
  34. title: "Name",
  35. field: "name",
  36. width: 200,
  37. formatter: "html"
  38. },
  39. {
  40. title: "Slug",
  41. field: "slug",
  42. width: 120
  43. },
  44. {
  45. title: "Domain",
  46. field: "domain",
  47. width: 150
  48. },
  49. {
  50. title: "Locale",
  51. field: "locale",
  52. width: 80
  53. },
  54. {
  55. title: "Status",
  56. field: "status",
  57. width: 100,
  58. formatter: "html"
  59. },
  60. {
  61. title: "Content",
  62. field: "content_counts",
  63. width: 150,
  64. formatter: "html"
  65. },
  66. {
  67. title: "Overrides",
  68. field: "overrides_count",
  69. width: 100
  70. },
  71. {
  72. title: "Created",
  73. field: "created_at",
  74. width: 150,
  75. formatter: "datetime",
  76. formatterParams: {
  77. inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
  78. outputFormat: "DD/MM/YYYY HH:mm"
  79. }
  80. },
  81. {
  82. title: "Actions",
  83. field: "actions",
  84. width: 120,
  85. headerSort: false,
  86. formatter: "html"
  87. }
  88. ]
  89. end
  90. format.json { render json: channels_json }
  91. end
  92. end
  93. def show
  94. @overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
  95. end
  96. def new
  97. @channel = Channel.new
  98. end
  99. def create
  100. @channel = Channel.new(channel_params)
  101. if @channel.save
  102. redirect_to admin_system_channel_path(@channel), notice: 'Channel was successfully created.'
  103. else
  104. render :new
  105. end
  106. end
  107. def edit
  108. end
  109. def update
  110. if @channel.update(channel_params)
  111. redirect_to admin_system_channel_path(@channel), notice: 'Channel was successfully updated.'
  112. else
  113. render :edit
  114. end
  115. end
  116. def destroy
  117. @channel.destroy
  118. redirect_to admin_system_channels_path, notice: 'Channel was successfully deleted.'
  119. end
  120. private
  121. def set_channel
  122. @channel = Channel.find(params[:id])
  123. end
  124. def channel_params
  125. params.require(:channel).permit(:name, :slug, :domain, :locale, :enabled, metadata: {}, settings: {})
  126. end
  127. def channels_json
  128. @channels.map do |channel|
  129. {
  130. id: channel.id,
  131. name: "<a href='#{admin_system_channel_path(channel)}' class='text-indigo-400 hover:text-indigo-300 font-medium'>#{channel.name}</a>",
  132. slug: channel.slug,
  133. domain: channel.domain || '-',
  134. locale: channel.locale.upcase,
  135. status: channel.enabled? ?
  136. "<span class='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800'>Enabled</span>" :
  137. "<span class='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800'>Disabled</span>",
  138. content_counts: "#{channel.posts.count} posts, #{channel.pages.count} pages, #{channel.media.count} media",
  139. overrides_count: channel.channel_overrides.count,
  140. created_at: channel.created_at.iso8601,
  141. actions: "<div class='flex items-center space-x-2'>
  142. <a href='#{admin_system_channel_path(channel)}' class='text-gray-400 hover:text-white' title='View'>
  143. <svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
  144. <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 12a3 3 0 11-6 0 3 3 0 016 0z'/>
  145. <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z'/>
  146. </svg>
  147. </a>
  148. <a href='#{edit_admin_system_channel_path(channel)}' class='text-gray-400 hover:text-white' title='Edit'>
  149. <svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
  150. <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'/>
  151. </svg>
  152. </a>
  153. <a href='#{admin_system_channel_channel_overrides_path(channel)}' class='text-gray-400 hover:text-white' title='Overrides'>
  154. <svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
  155. <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'/>
  156. <path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 12a3 3 0 11-6 0 3 3 0 016 0z'/>
  157. </svg>
  158. </a>
  159. </div>"
  160. }
  161. end
  162. end
  163. end
  164. end
  165. end

app/controllers/admin/system/headless_controller.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::System::HeadlessController < Admin::BaseController
  2. def index
  3. @headless_enabled = SiteSetting.get('headless_mode', false)
  4. @cors_enabled = SiteSetting.get('cors_enabled', false)
  5. @cors_origins = SiteSetting.get('cors_origins', '*')
  6. @cors_methods = SiteSetting.get('cors_methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
  7. @cors_headers = SiteSetting.get('cors_headers', '*')
  8. end
  9. def update
  10. headless_enabled = params[:headless_mode] == '1'
  11. cors_enabled = params[:cors_enabled] == '1'
  12. SiteSetting.set('headless_mode', headless_enabled)
  13. SiteSetting.set('cors_enabled', cors_enabled)
  14. SiteSetting.set('cors_origins', params[:cors_origins]) if params[:cors_origins].present?
  15. SiteSetting.set('cors_methods', params[:cors_methods]) if params[:cors_methods].present?
  16. SiteSetting.set('cors_headers', params[:cors_headers]) if params[:cors_headers].present?
  17. if headless_enabled
  18. flash[:notice] = "Headless mode enabled. Frontend routes are now disabled. Access your content via GraphQL and REST APIs."
  19. else
  20. flash[:notice] = "Headless mode disabled. Frontend routes are now enabled."
  21. end
  22. redirect_to admin_system_headless_path
  23. end
  24. def test_cors
  25. render json: {
  26. success: true,
  27. message: "CORS is configured correctly",
  28. cors_origins: SiteSetting.get('cors_origins', '*'),
  29. timestamp: Time.current
  30. }
  31. end
  32. end

app/controllers/admin/tags_controller.rb

0.0% lines covered

100.0% branches covered

77 relevant lines. 0 lines covered and 77 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TagsController < Admin::BaseController
  2. before_action :set_taxonomy
  3. before_action :set_term, only: %i[ show edit update destroy ]
  4. # GET /admin/tags or /admin/tags.json
  5. def index
  6. @terms = @taxonomy.terms.includes(:term_relationships).order(:name)
  7. respond_to do |format|
  8. format.html
  9. format.json {
  10. render json: @terms.map { |term|
  11. {
  12. id: term.id,
  13. name: term.name,
  14. slug: term.slug,
  15. description: term.description,
  16. posts_count: term.term_relationships.where(object_type: 'Post').count,
  17. created_at: term.created_at.strftime('%B %d, %Y')
  18. }
  19. }
  20. }
  21. end
  22. end
  23. # GET /admin/tags/1 or /admin/tags/1.json
  24. def show
  25. @posts = Post.joins(:term_relationships)
  26. .where(term_relationships: { term_id: @term.id })
  27. .order(created_at: :desc)
  28. .page(params[:page])
  29. end
  30. # GET /admin/tags/new
  31. def new
  32. @term = @taxonomy.terms.new
  33. end
  34. # GET /admin/tags/1/edit
  35. def edit
  36. end
  37. # POST /admin/tags or /admin/tags.json
  38. def create
  39. @term = @taxonomy.terms.new(term_params)
  40. respond_to do |format|
  41. if @term.save
  42. format.html { redirect_to admin_tag_path(@term), notice: "Tag was successfully created." }
  43. format.json { render :show, status: :created, location: admin_tag_path(@term) }
  44. else
  45. format.html { render :new, status: :unprocessable_entity }
  46. format.json { render json: @term.errors, status: :unprocessable_entity }
  47. end
  48. end
  49. end
  50. # PATCH/PUT /admin/tags/1 or /admin/tags/1.json
  51. def update
  52. respond_to do |format|
  53. if @term.update(term_params)
  54. format.html { redirect_to admin_tag_path(@term), notice: "Tag was successfully updated.", status: :see_other }
  55. format.json { render :show, status: :ok, location: admin_tag_path(@term) }
  56. else
  57. format.html { render :edit, status: :unprocessable_entity }
  58. format.json { render json: @term.errors, status: :unprocessable_entity }
  59. end
  60. end
  61. end
  62. # DELETE /admin/tags/1 or /admin/tags/1.json
  63. def destroy
  64. @term.destroy!
  65. respond_to do |format|
  66. format.html { redirect_to admin_tags_path, notice: "Tag was successfully deleted.", status: :see_other }
  67. format.json { head :no_content }
  68. end
  69. end
  70. private
  71. # Set the tag taxonomy
  72. def set_taxonomy
  73. @taxonomy = Taxonomy.find_by!(slug: 'tag')
  74. rescue ActiveRecord::RecordNotFound
  75. redirect_to admin_taxonomies_path, alert: "Tag taxonomy not found. Please run seeds."
  76. end
  77. # Use callbacks to share common setup or constraints between actions.
  78. def set_term
  79. @term = @taxonomy.terms.find(params[:id])
  80. rescue ActiveRecord::RecordNotFound
  81. redirect_to admin_tags_path, alert: "Tag not found."
  82. end
  83. # Only allow a list of trusted parameters through.
  84. def term_params
  85. params.require(:term).permit(:name, :slug, :description, :meta)
  86. end
  87. end

app/controllers/admin/taxonomies_controller.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TaxonomiesController < Admin::BaseController
  2. before_action :set_taxonomy, only: [:show, :edit, :update, :destroy]
  3. # GET /admin/taxonomies
  4. def index
  5. @taxonomies = Taxonomy.all.order(created_at: :desc)
  6. end
  7. # GET /admin/taxonomies/:id
  8. def show
  9. @terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
  10. end
  11. # GET /admin/taxonomies/new
  12. def new
  13. @taxonomy = Taxonomy.new
  14. end
  15. # GET /admin/taxonomies/:id/edit
  16. def edit
  17. end
  18. # POST /admin/taxonomies
  19. def create
  20. @taxonomy = Taxonomy.new(taxonomy_params)
  21. if @taxonomy.save
  22. redirect_to admin_taxonomies_path, notice: 'Taxonomy was successfully created.'
  23. else
  24. render :new, status: :unprocessable_entity
  25. end
  26. end
  27. # PATCH/PUT /admin/taxonomies/:id
  28. def update
  29. if @taxonomy.update(taxonomy_params)
  30. redirect_to admin_taxonomy_path(@taxonomy), notice: 'Taxonomy was successfully updated.'
  31. else
  32. render :edit, status: :unprocessable_entity
  33. end
  34. end
  35. # DELETE /admin/taxonomies/:id
  36. def destroy
  37. @taxonomy.destroy
  38. redirect_to admin_taxonomies_path, notice: 'Taxonomy was successfully deleted.'
  39. end
  40. private
  41. def set_taxonomy
  42. @taxonomy = Taxonomy.friendly.find(params[:id])
  43. end
  44. def taxonomy_params
  45. params.require(:taxonomy).permit(:name, :slug, :description, :hierarchical, object_types: [], settings: {})
  46. end
  47. end

app/controllers/admin/template_customizer_controller.rb

0.0% lines covered

100.0% branches covered

304 relevant lines. 0 lines covered and 304 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TemplateCustomizerController < Admin::BaseController
  2. layout :resolve_layout
  3. before_action :load_current_theme, only: [:index, :customize, :load_template_content, :load_section_schema, :save_customization, :publish_customization, :test_data]
  4. before_action :set_template_data, only: [:customize, :save_customization]
  5. def index
  6. # Redirect to customize action for the current theme
  7. redirect_to admin_template_customizer_customize_path(template: 'index')
  8. end
  9. def customize
  10. @template_type = params[:template] || 'index'
  11. @template_data = load_template_data(@template_type)
  12. @available_templates = get_available_templates
  13. @theme_sections = load_theme_sections(@template_type)
  14. @theme_settings = load_theme_settings
  15. # Debug logging
  16. Rails.logger.info "Theme sections loaded: #{@theme_sections.keys}"
  17. Rails.logger.info "Theme sections JSON: #{@theme_sections.to_json}"
  18. render layout: 'editor_fullscreen'
  19. end
  20. def test_data
  21. @template_type = params[:template] || 'index'
  22. @template_data = load_template_data(@template_type)
  23. @available_templates = get_available_templates
  24. @theme_sections = load_theme_sections(@template_type)
  25. @theme_settings = load_theme_settings
  26. render json: {
  27. themeSections: @theme_sections.map { |k, v| [k, { 'type' => v['type'], 'name' => v['name'], 'schema' => v['schema'] }] }.to_h,
  28. themeSettings: @theme_settings,
  29. templateData: @template_data
  30. }
  31. end
  32. def save_customization
  33. template_type = params[:template_type]
  34. template_data = JSON.parse(params[:template_data])
  35. begin
  36. # Create a preview theme version
  37. theme_version = ThemeVersion.create_preview(
  38. @current_theme,
  39. current_user,
  40. summary: "Customized #{template_type} template"
  41. )
  42. # Update the template in the theme version
  43. service = ThemeVersionService.new(theme_version)
  44. service.update_template(template_type, template_data)
  45. respond_to do |format|
  46. format.json { render json: { success: true, message: 'Preview saved successfully', version_id: theme_version.id } }
  47. end
  48. rescue => e
  49. Rails.logger.error "Error saving customization: #{e.message}"
  50. respond_to do |format|
  51. format.json { render json: { success: false, errors: [e.message] }, status: :unprocessable_entity }
  52. end
  53. end
  54. end
  55. def publish_customization
  56. template_type = params[:template_type]
  57. template_data = JSON.parse(params[:template_data])
  58. begin
  59. # Create a live theme version
  60. theme_version = ThemeVersion.create_live_version(
  61. @current_theme,
  62. current_user,
  63. summary: "Published #{template_type} template"
  64. )
  65. # Update the template in the theme version
  66. service = ThemeVersionService.new(theme_version)
  67. service.update_template(template_type, template_data)
  68. respond_to do |format|
  69. format.json { render json: { success: true, message: 'Theme published successfully', version_id: theme_version.id } }
  70. end
  71. rescue => e
  72. Rails.logger.error "Error publishing customization: #{e.message}"
  73. respond_to do |format|
  74. format.json { render json: { success: false, errors: [e.message] }, status: :unprocessable_entity }
  75. end
  76. end
  77. end
  78. def load_template_content
  79. template_type = params[:template_type] || 'index'
  80. # Get the current live theme version or fallback to base theme files
  81. live_version = ThemeVersion.live.for_theme(@current_theme).first
  82. if live_version
  83. # Use live version data
  84. template_data = live_version.template_data(template_type)
  85. sections_data = load_sections_from_version(live_version)
  86. else
  87. # Fallback to base theme files (read-only)
  88. template_data = load_template_data(template_type)
  89. sections_data = load_theme_sections(template_type)
  90. end
  91. # Render preview HTML using the theme version or base files
  92. begin
  93. preview_html = render_theme_preview(template_type, template_data, sections_data)
  94. rescue => e
  95. Rails.logger.error "Error rendering theme preview: #{e.message}"
  96. Rails.logger.error e.backtrace.join("\n")
  97. preview_html = "<div style='padding: 20px; color: red;'>Error rendering preview: #{e.message}</div>"
  98. end
  99. render json: {
  100. html: preview_html,
  101. template_data: template_data,
  102. sections: sections_data,
  103. settings: load_theme_settings
  104. }
  105. end
  106. def load_section_schema
  107. section_type = params[:section_type]
  108. schema = get_section_schema(section_type)
  109. render json: { schema: schema }
  110. end
  111. private
  112. def load_current_theme
  113. @current_theme = Railspress::ThemeLoader.current_theme
  114. @theme_config = Railspress::ThemeLoader.theme_config
  115. @theme_path = Rails.root.join('app', 'themes', @current_theme)
  116. end
  117. def set_template_data
  118. @template_type = params[:template] || 'index'
  119. end
  120. def load_template_data(template_type)
  121. template_file = @theme_path.join('templates', "#{template_type}.json")
  122. if File.exist?(template_file)
  123. JSON.parse(File.read(template_file))
  124. else
  125. # Return default template structure
  126. {
  127. 'sections' => {},
  128. 'order' => []
  129. }
  130. end
  131. end
  132. def get_available_templates
  133. templates_dir = @theme_path.join('templates')
  134. return [] unless Dir.exist?(templates_dir)
  135. Dir.entries(templates_dir)
  136. .select { |f| f.end_with?('.json') }
  137. .map { |f| f.chomp('.json') }
  138. .reject { |f| f == 'index' } # index is the default
  139. .unshift('index') # Put index first
  140. end
  141. def load_theme_sections(template_type)
  142. sections_dir = @theme_path.join('sections')
  143. return {} unless Dir.exist?(sections_dir)
  144. sections = {}
  145. Dir.entries(sections_dir).each do |file|
  146. next unless file.end_with?('.liquid')
  147. section_type = file.chomp('.liquid')
  148. section_file = sections_dir.join(file)
  149. begin
  150. content = File.read(section_file)
  151. # Extract schema from liquid file
  152. schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
  153. schema = {}
  154. if schema_match
  155. begin
  156. schema = JSON.parse(schema_match[1])
  157. rescue JSON::ParserError => e
  158. Rails.logger.warn "Failed to parse schema for #{section_type}: #{e.message}"
  159. schema = {}
  160. end
  161. end
  162. sections[section_type] = {
  163. 'type' => section_type,
  164. 'name' => schema['name'] || section_type.humanize,
  165. 'schema' => schema,
  166. 'content' => content
  167. }
  168. rescue => e
  169. Rails.logger.error "Error loading section #{section_type}: #{e.message}"
  170. # Still add the section with basic info
  171. sections[section_type] = {
  172. 'type' => section_type,
  173. 'name' => section_type.humanize,
  174. 'schema' => {},
  175. 'content' => ''
  176. }
  177. end
  178. end
  179. sections
  180. end
  181. def load_theme_settings
  182. settings_file = @theme_path.join('config', 'settings_schema.json')
  183. if File.exist?(settings_file)
  184. JSON.parse(File.read(settings_file))
  185. else
  186. []
  187. end
  188. end
  189. def get_section_schema(section_type)
  190. section_file = @theme_path.join('sections', "#{section_type}.liquid")
  191. if File.exist?(section_file)
  192. content = File.read(section_file)
  193. schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
  194. schema_match ? JSON.parse(schema_match[1]) : {}
  195. else
  196. {}
  197. end
  198. end
  199. def render_liquid_template(template_type, template_data)
  200. renderer = LiquidTemplateRenderer.new(@current_theme, template_type, template_data)
  201. renderer.render
  202. end
  203. def create_theme_version(template_type, template_data)
  204. # Create a new theme file version for the template
  205. ThemeFileVersion.create!(
  206. theme_name: @current_theme,
  207. file_path: "templates/#{template_type}.json",
  208. content: template_data.to_json,
  209. file_size: template_data.to_json.bytesize,
  210. user_id: current_user&.id,
  211. change_summary: "Updated #{template_type} template via customizer"
  212. )
  213. end
  214. def load_sections_from_version(theme_version)
  215. sections_data = {}
  216. theme_version.sections.includes(:theme_file).each do |file_version|
  217. section_type = file_version.file_path.gsub('sections/', '').gsub('.liquid', '')
  218. sections_data[section_type] = {
  219. 'type' => section_type,
  220. 'schema' => file_version.theme_file&.parsed_schema || {},
  221. 'content' => file_version.content
  222. }
  223. end
  224. sections_data
  225. end
  226. def render_theme_preview(template_type, template_data, sections_data)
  227. begin
  228. # If we have template data with sections, render them
  229. if template_data && template_data['order'] && template_data['sections']
  230. return render_sections_from_template_data(template_data, template_type)
  231. else
  232. # Fallback to basic template structure
  233. return render_basic_template(template_type)
  234. end
  235. rescue => e
  236. Rails.logger.error "Error in render_theme_preview: #{e.message}"
  237. Rails.logger.error e.backtrace.join("\n")
  238. return "<div style='padding: 20px; color: red;'>Error rendering preview: #{e.message}</div>"
  239. end
  240. end
  241. def render_sections_from_template_data(template_data, template_type)
  242. sections_html = ''
  243. template_data['order'].each do |section_id|
  244. section_data = template_data['sections'][section_id]
  245. next unless section_data
  246. section_type = section_data['type']
  247. # For now, just use fallback HTML to avoid Liquid parsing issues
  248. # TODO: Implement proper Liquid parsing with Shopify tag support
  249. sections_html += generate_fallback_section_html(section_type, section_data)
  250. end
  251. # Wrap in basic HTML structure
  252. wrap_in_html_template(sections_html, template_type)
  253. end
  254. def render_basic_template(template_type)
  255. # Return a basic HTML structure for the template type
  256. case template_type
  257. when 'index'
  258. content = '<section class="hero-section"><h1>Welcome to our site</h1><p>This is the homepage content.</p></section>'
  259. when 'blog'
  260. content = '<section class="blog-section"><h1>Blog</h1><p>Latest blog posts will appear here.</p></section>'
  261. when 'page'
  262. content = '<section class="page-section"><h1>Page Title</h1><p>Page content goes here.</p></section>'
  263. when 'post'
  264. content = '<section class="post-section"><h1>Blog Post Title</h1><p>Blog post content goes here.</p></section>'
  265. else
  266. content = "<section class=\"#{template_type}-section\"><h1>#{template_type.humanize}</h1><p>Content for #{template_type} page.</p></section>"
  267. end
  268. wrap_in_html_template(content, template_type)
  269. end
  270. def wrap_in_html_template(content, template_type)
  271. <<~HTML
  272. <!DOCTYPE html>
  273. <html>
  274. <head>
  275. <meta charset="utf-8">
  276. <title>#{template_type.humanize} - Preview</title>
  277. <style>
  278. body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
  279. .hero-section { background: #f0f0f0; padding: 40px; text-align: center; }
  280. .blog-section, .page-section, .post-section { padding: 20px; }
  281. </style>
  282. </head>
  283. <body>
  284. #{content}
  285. </body>
  286. </html>
  287. HTML
  288. end
  289. def generate_fallback_section_html(section_type, section_data)
  290. section_name = section_data['name'] || section_type.humanize
  291. <<~HTML
  292. <section class="#{section_type}-section" data-section-type="#{section_type}">
  293. <div class="section-content">
  294. <h2>#{section_name}</h2>
  295. <p>This #{section_name.downcase} section will be loaded from theme files.</p>
  296. </div>
  297. </section>
  298. HTML
  299. end
  300. def load_section_content(section_type)
  301. section_file = @theme_path.join('sections', "#{section_type}.liquid")
  302. if File.exist?(section_file)
  303. File.read(section_file)
  304. else
  305. nil
  306. end
  307. end
  308. def load_page_data(template_type)
  309. case template_type
  310. when 'index'
  311. { 'title' => 'Homepage', 'description' => 'Welcome to our site' }
  312. when 'blog'
  313. { 'title' => 'Blog', 'description' => 'Latest posts' }
  314. when 'page'
  315. { 'title' => 'Page', 'description' => 'Page content' }
  316. when 'post'
  317. { 'title' => 'Blog Post', 'description' => 'Post content' }
  318. else
  319. { 'title' => template_type.humanize, 'description' => '' }
  320. end
  321. end
  322. def resolve_layout
  323. action_name == 'customize' ? 'editor_fullscreen' : 'admin'
  324. end
  325. end

app/controllers/admin/terms_controller.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TermsController < Admin::BaseController
  2. before_action :set_taxonomy
  3. before_action :set_term, only: [:edit, :update, :destroy]
  4. # GET /admin/taxonomies/:taxonomy_id/terms
  5. def index
  6. @terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
  7. @term = Term.new(taxonomy: @taxonomy)
  8. end
  9. # POST /admin/taxonomies/:taxonomy_id/terms
  10. def create
  11. @term = @taxonomy.terms.build(term_params)
  12. if @term.save
  13. redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully created.'
  14. else
  15. @terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
  16. render :index, status: :unprocessable_entity
  17. end
  18. end
  19. # GET /admin/taxonomies/:taxonomy_id/terms/:id/edit
  20. def edit
  21. end
  22. # PATCH/PUT /admin/taxonomies/:taxonomy_id/terms/:id
  23. def update
  24. if @term.update(term_params)
  25. redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully updated.'
  26. else
  27. render :edit, status: :unprocessable_entity
  28. end
  29. end
  30. # DELETE /admin/taxonomies/:taxonomy_id/terms/:id
  31. def destroy
  32. @term.destroy
  33. redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully deleted.'
  34. end
  35. private
  36. def set_taxonomy
  37. @taxonomy = Taxonomy.friendly.find(params[:taxonomy_id])
  38. end
  39. def set_term
  40. @term = @taxonomy.terms.friendly.find(params[:id])
  41. end
  42. def term_params
  43. params.require(:term).permit(:name, :slug, :description, :parent_id, metadata: {})
  44. end
  45. end

app/controllers/admin/theme_editor_controller.rb

0.0% lines covered

100.0% branches covered

169 relevant lines. 0 lines covered and 169 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ThemeEditorController < Admin::BaseController
  2. layout :resolve_layout
  3. before_action :set_themes_manager
  4. before_action :set_active_theme
  5. before_action :set_current_file, only: [:edit, :update, :destroy, :download, :versions, :restore]
  6. def index
  7. @file_tree = @themes_manager.file_tree(@active_theme.name)
  8. @current_file_path = params[:file]
  9. if @current_file_path
  10. @file_content = @themes_manager.get_file(@current_file_path)
  11. @file_versions = get_file_versions(@current_file_path)
  12. end
  13. render layout: 'editor'
  14. end
  15. def edit
  16. respond_to do |format|
  17. format.turbo_stream do
  18. render turbo_stream: turbo_stream.replace(
  19. 'file-editor',
  20. partial: 'admin/theme_editor/editor',
  21. locals: { file_path: @current_file_path, content: @file_content, versions: @file_versions }
  22. )
  23. end
  24. format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path) }
  25. end
  26. end
  27. def update
  28. # Get the theme file and create new version
  29. theme_file = ThemeFile.find_by(theme_name: @active_theme.name, file_path: @current_file_path)
  30. if theme_file
  31. version = @themes_manager.create_file_version(theme_file, file_params[:content], current_user)
  32. respond_to do |format|
  33. format.turbo_stream do
  34. render turbo_stream: [
  35. turbo_stream.replace('flash-messages', partial: 'admin/shared/flash', locals: {
  36. notice: 'File saved successfully!'
  37. }),
  38. turbo_stream.replace('file-versions', partial: 'admin/theme_editor/versions', locals: {
  39. versions: get_file_versions(@current_file_path)
  40. })
  41. ]
  42. end
  43. format.json { render json: { success: true, message: 'File saved!' } }
  44. format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path), notice: 'File saved successfully!' }
  45. end
  46. else
  47. respond_to do |format|
  48. format.turbo_stream do
  49. render turbo_stream: turbo_stream.replace('flash-messages', partial: 'admin/shared/flash', locals: {
  50. alert: @manager.errors.join(', ')
  51. })
  52. end
  53. format.json { render json: { success: false, errors: @manager.errors }, status: :unprocessable_entity }
  54. format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @manager.errors.join(', ') }
  55. end
  56. end
  57. end
  58. def create
  59. file_path = params[:file_path]
  60. content = params[:content] || ''
  61. if @themes_manager.create_file(file_path, content)
  62. render json: { success: true, message: 'File created successfully!', file_path: file_path }
  63. else
  64. render json: { success: false, errors: @themes_manager.errors }, status: :unprocessable_entity
  65. end
  66. end
  67. def destroy
  68. if @themes_manager.delete_file(@current_file_path)
  69. redirect_to admin_theme_editor_index_path, notice: 'File deleted successfully!'
  70. else
  71. redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @themes_manager.errors.join(', ')
  72. end
  73. end
  74. def rename
  75. old_path = params[:old_path]
  76. new_path = params[:new_path]
  77. if @themes_manager.rename_file(old_path, new_path)
  78. render json: { success: true, message: 'File renamed successfully!', new_path: new_path }
  79. else
  80. render json: { success: false, errors: @themes_manager.errors }, status: :unprocessable_entity
  81. end
  82. end
  83. def download
  84. full_path = File.join(@themes_manager.themes_path, @active_theme.name, @current_file_path)
  85. if File.exist?(full_path)
  86. send_file full_path, filename: File.basename(@current_file_path)
  87. else
  88. redirect_to admin_theme_editor_index_path, alert: 'File not found'
  89. end
  90. end
  91. def search
  92. query = params[:query]
  93. results = @themes_manager.search(query)
  94. render json: { results: results, count: results.size }
  95. end
  96. def versions
  97. @versions = @themes_manager.file_versions(@current_file_path)
  98. respond_to do |format|
  99. format.html
  100. format.json { render json: @versions }
  101. end
  102. end
  103. def restore
  104. version_id = params[:version_id]
  105. if @themes_manager.restore_version(version_id)
  106. redirect_to admin_theme_editor_index_path(file: @current_file_path), notice: 'Version restored successfully!'
  107. else
  108. redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @themes_manager.errors.join(', ')
  109. end
  110. end
  111. def preview
  112. # Render preview iframe
  113. render layout: false
  114. end
  115. def open_file
  116. file_path = params[:file]
  117. if file_path.present?
  118. @current_file_path = file_path
  119. @file_content = @themes_manager.read_file(@current_file_path)
  120. @file_versions = @themes_manager.file_versions(@current_file_path)
  121. if @file_content.nil?
  122. redirect_to admin_theme_editor_index_path, alert: @themes_manager.errors.join(', ')
  123. return
  124. end
  125. respond_to do |format|
  126. format.turbo_stream do
  127. render turbo_stream: [
  128. turbo_stream.replace('file-editor', partial: 'admin/theme_editor/editor', locals: {
  129. file_path: @current_file_path,
  130. content: @file_content,
  131. versions: @file_versions
  132. })
  133. ]
  134. end
  135. format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path) }
  136. end
  137. else
  138. redirect_to admin_theme_editor_index_path, alert: 'No file specified'
  139. end
  140. end
  141. def test
  142. render layout: false
  143. end
  144. private
  145. def set_themes_manager
  146. @themes_manager = ThemesManager.new
  147. end
  148. def set_active_theme
  149. @active_theme = Theme.active.first
  150. redirect_to admin_themes_path, alert: 'No active theme found. Please activate a theme first.' unless @active_theme
  151. end
  152. def set_current_file
  153. @current_file_path = params[:file] || params[:id]
  154. @file_content = @themes_manager.get_file(@current_file_path)
  155. @file_versions = get_file_versions(@current_file_path)
  156. if @file_content.nil?
  157. redirect_to admin_theme_editor_index_path, alert: 'File not found or could not be read.'
  158. end
  159. end
  160. def get_file_versions(file_path)
  161. theme_file = ThemeFile.find_by(theme_name: @active_theme.name, file_path: file_path)
  162. return [] unless theme_file
  163. theme_file.theme_file_versions.order(version_number: :desc)
  164. end
  165. def file_params
  166. params.require(:file).permit(:content, :change_summary)
  167. end
  168. def resolve_layout
  169. action_name == 'index' ? 'editor' : 'admin'
  170. end
  171. end

app/controllers/admin/themes_controller.rb

0.0% lines covered

100.0% branches covered

179 relevant lines. 0 lines covered and 179 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::ThemesController < Admin::BaseController
  2. before_action :ensure_admin, only: [:activate, :destroy, :sync]
  3. before_action :set_themes_manager
  4. # GET /admin/themes
  5. def index
  6. # Auto-sync themes from filesystem if none exist or if filesystem has themes not in database
  7. filesystem_themes = Dir.glob(File.join(Rails.root, 'app', 'themes', '*')).map { |dir| File.basename(dir) }
  8. database_themes = Theme.pluck(:slug)
  9. if Theme.count == 0 || filesystem_themes.any? { |theme| !database_themes.include?(theme) }
  10. Rails.logger.info "Auto-syncing themes from filesystem..."
  11. @themes_manager.sync_themes
  12. end
  13. @active_theme = Theme.active.first
  14. @installed_themes = Theme.all.order(:name)
  15. # Convert Theme objects to hash structure expected by the view
  16. @available_themes = @installed_themes.map do |theme|
  17. {
  18. id: theme.id,
  19. name: theme.name,
  20. display_name: theme.name,
  21. description: theme.description || "No description available",
  22. author: theme.author || "Unknown",
  23. version: theme.version || "1.0.0",
  24. active: theme.active
  25. }
  26. end
  27. end
  28. # GET /admin/themes/1
  29. def show
  30. @theme = Theme.find(params[:id])
  31. end
  32. # GET /admin/themes/new
  33. def new
  34. @theme = Theme.new
  35. end
  36. # GET /admin/themes/1/edit
  37. def edit
  38. @theme = Theme.find(params[:id])
  39. end
  40. # POST /admin/themes
  41. def create
  42. @theme = Theme.new(theme_params)
  43. respond_to do |format|
  44. if @theme.save
  45. format.html { redirect_to admin_themes_path, notice: "Theme was successfully created." }
  46. format.json { render :show, status: :created, location: @theme }
  47. else
  48. format.html { render :new, status: :unprocessable_entity }
  49. format.json { render json: @theme.errors, status: :unprocessable_entity }
  50. end
  51. end
  52. end
  53. # PATCH/PUT /admin/themes/1
  54. def update
  55. @theme = Theme.find(params[:id])
  56. respond_to do |format|
  57. if @theme.update(theme_params)
  58. format.html { redirect_to admin_themes_path, notice: "Theme was successfully updated." }
  59. format.json { render :show, status: :ok, location: @theme }
  60. else
  61. format.html { render :edit, status: :unprocessable_entity }
  62. format.json { render json: @theme.errors, status: :unprocessable_entity }
  63. end
  64. end
  65. end
  66. # DELETE /admin/themes/1
  67. def destroy
  68. @theme = Theme.find(params[:id])
  69. if @theme.active?
  70. redirect_to admin_themes_path, alert: "Cannot delete active theme."
  71. else
  72. @theme.destroy
  73. redirect_to admin_themes_path, notice: "Theme was successfully deleted."
  74. end
  75. end
  76. # PATCH /admin/themes/1/activate
  77. def activate
  78. @theme = Theme.find(params[:id])
  79. if @theme.activate!
  80. flash[:notice] = "✓ Theme '#{@theme.name}' activated successfully! View your frontend to see the changes."
  81. else
  82. flash[:alert] = "✗ Failed to activate theme '#{@theme.name}'. Please check the theme files."
  83. end
  84. redirect_to admin_themes_path
  85. end
  86. # POST /admin/themes/sync
  87. def sync
  88. synced_count = @themes_manager.sync_themes
  89. if synced_count > 0
  90. flash[:notice] = "✓ Synced #{synced_count} themes from filesystem to database."
  91. else
  92. flash[:info] = "All themes are already up to date."
  93. end
  94. redirect_to admin_themes_path
  95. end
  96. # GET /admin/themes/:id/load_customizer
  97. def load_customizer
  98. theme = Theme.find(params[:id])
  99. # Only sync if no published version exists
  100. unless theme.published_version
  101. @themes_manager.sync_theme(theme.slug)
  102. theme.reload
  103. end
  104. # Ensure published version exists
  105. theme.ensure_published_version_exists!
  106. # Find or create BuilderTheme
  107. builder_theme = BuilderTheme.current_for_theme(theme.name.underscore)
  108. if builder_theme
  109. redirect_to admin_builder_path(builder_theme)
  110. else
  111. redirect_to admin_builder_index_path(theme_name: theme.name)
  112. end
  113. end
  114. # GET /admin/themes/:id/load_preview
  115. def load_preview
  116. theme = Theme.find(params[:id])
  117. # Only sync if no published version exists
  118. unless theme.published_version
  119. @themes_manager.sync_theme(theme.slug)
  120. theme.reload
  121. end
  122. # Ensure published version exists
  123. theme.ensure_published_version_exists!
  124. # Redirect to preview
  125. redirect_to preview_admin_themes_path(id: theme.id)
  126. end
  127. # GET /admin/themes/preview?id=theme_id
  128. def preview
  129. @theme_id = params[:id]
  130. @theme = Theme.find(@theme_id)
  131. @theme_name = @theme.name
  132. @theme_config = load_theme_config(@theme_name)
  133. # Ensure theme has a published version
  134. @theme.ensure_published_version_exists!
  135. published_version = @theme.published_version
  136. # If still no published version, create one
  137. unless published_version
  138. @theme.ensure_published_version_exists!
  139. published_version = @theme.published_version
  140. end
  141. if published_version
  142. # Use FrontendRendererService for proper rendering
  143. renderer = FrontendRendererService.new(published_version)
  144. template_type = params[:template] || 'index'
  145. begin
  146. @preview_html = renderer.render_template(template_type, preview_context)
  147. @assets = renderer.assets
  148. rescue => e
  149. Rails.logger.error "Theme preview rendering failed: #{e.message}"
  150. @preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
  151. @assets = { css: '', js: '' }
  152. end
  153. else
  154. @preview_html = "<div style='padding: 20px; color: red;'>No published version found for #{@theme_name}</div>"
  155. @assets = { css: '', js: '' }
  156. end
  157. render 'preview', layout: false
  158. end
  159. private
  160. def set_themes_manager
  161. @themes_manager = ThemesManager.new
  162. end
  163. def theme_params
  164. params.require(:theme).permit(:name, :description, :version, :active, :config)
  165. end
  166. def load_theme_config(theme_name)
  167. theme = Theme.find_by(name: theme_name)
  168. return {} unless theme
  169. config_path = Rails.root.join('app', 'themes', theme.slug, 'config', 'theme.json')
  170. if File.exist?(config_path)
  171. JSON.parse(File.read(config_path))
  172. else
  173. {}
  174. end
  175. rescue JSON::ParserError
  176. {}
  177. end
  178. def preview_context
  179. {
  180. # Page context
  181. 'page' => {
  182. 'title' => 'Theme Preview',
  183. 'description' => 'Preview of the theme',
  184. 'url' => '/preview',
  185. 'seo_title' => 'Theme Preview - RailsPress',
  186. 'meta_description' => 'Preview of the selected theme',
  187. 'template' => 'index'
  188. },
  189. # Site context
  190. 'site' => {
  191. 'title' => 'RailsPress Site',
  192. 'description' => 'A sample RailsPress site',
  193. 'url' => 'https://example.com',
  194. 'name' => 'RailsPress Site',
  195. 'tagline' => 'Built with Rails'
  196. },
  197. # Sample content
  198. 'posts' => [],
  199. 'pages' => [],
  200. 'current_user' => nil,
  201. 'settings' => {},
  202. 'theme_settings' => {}
  203. }
  204. end
  205. end

app/controllers/admin/tools/erase_personal_data_controller.rb

0.0% lines covered

100.0% branches covered

63 relevant lines. 0 lines covered and 63 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::ErasePersonalDataController < Admin::BaseController
  2. # GET /admin/tools/erase_personal_data
  3. def index
  4. @erasure_requests = PersonalDataErasureRequest.order(created_at: :desc).limit(50) rescue []
  5. end
  6. # POST /admin/tools/erase_personal_data/request
  7. def request
  8. email = params[:email]
  9. reason = params[:reason]
  10. unless email.present?
  11. redirect_to admin_erase_personal_data_path, alert: 'Please provide an email address'
  12. return
  13. end
  14. user = User.find_by(email: email)
  15. unless user
  16. redirect_to admin_erase_personal_data_path, alert: 'No user found with that email address'
  17. return
  18. end
  19. # Prevent erasing admin users
  20. if user.administrator?
  21. redirect_to admin_erase_personal_data_path,
  22. alert: 'Cannot erase data for administrator accounts. Please change their role first.'
  23. return
  24. end
  25. # Create erasure request
  26. erasure_request = PersonalDataErasureRequest.create!(
  27. user_id: user.id,
  28. email: email,
  29. requested_by: current_user.id,
  30. status: 'pending_confirmation',
  31. reason: reason,
  32. token: SecureRandom.hex(32),
  33. metadata: {
  34. user_posts_count: user.posts.count,
  35. user_comments_count: Comment.where(author_email: email).count,
  36. user_media_count: Medium.where(user_id: user.id).count rescue 0
  37. }
  38. )
  39. redirect_to admin_erase_personal_data_path,
  40. notice: "Erasure request created for #{email}. Awaiting final confirmation."
  41. rescue => e
  42. Rails.logger.error("Personal data erasure error: #{e.message}")
  43. redirect_to admin_erase_personal_data_path, alert: "Request failed: #{e.message}"
  44. end
  45. # POST /admin/tools/erase_personal_data/confirm/:token
  46. def confirm
  47. erasure_request = PersonalDataErasureRequest.find_by(token: params[:token])
  48. unless erasure_request
  49. redirect_to admin_erase_personal_data_path, alert: 'Erasure request not found'
  50. return
  51. end
  52. if erasure_request.status != 'pending_confirmation'
  53. redirect_to admin_erase_personal_data_path, alert: 'This request has already been processed'
  54. return
  55. end
  56. # Update status
  57. erasure_request.update!(
  58. status: 'processing',
  59. confirmed_at: Time.current,
  60. confirmed_by: current_user.id
  61. )
  62. # Queue the erasure job
  63. PersonalDataErasureWorker.perform_async(erasure_request.id)
  64. redirect_to admin_erase_personal_data_path,
  65. notice: "Personal data erasure confirmed and queued for processing."
  66. rescue => e
  67. Rails.logger.error("Personal data erasure confirmation error: #{e.message}")
  68. redirect_to admin_erase_personal_data_path, alert: "Confirmation failed: #{e.message}"
  69. end
  70. end

app/controllers/admin/tools/export_controller.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::ExportController < Admin::BaseController
  2. # GET /admin/tools/export
  3. def index
  4. @export_jobs = ExportJob.order(created_at: :desc).limit(20) rescue []
  5. end
  6. # POST /admin/tools/export/generate
  7. def generate
  8. export_type = params[:export_type] || 'json'
  9. export_options = params[:options] || {}
  10. # Create export job
  11. export_job = ExportJob.create!(
  12. export_type: export_type,
  13. user_id: current_user.id,
  14. status: 'pending',
  15. options: export_options,
  16. metadata: {
  17. include_posts: export_options[:include_posts] == '1',
  18. include_pages: export_options[:include_pages] == '1',
  19. include_media: export_options[:include_media] == '1',
  20. include_users: export_options[:include_users] == '1',
  21. include_settings: export_options[:include_settings] == '1',
  22. include_comments: export_options[:include_comments] == '1'
  23. }
  24. )
  25. # Queue the export job
  26. ExportWorker.perform_async(export_job.id)
  27. redirect_to admin_export_path, notice: 'Export started. You will be able to download it shortly...'
  28. rescue => e
  29. Rails.logger.error("Export generation error: #{e.message}")
  30. redirect_to admin_export_path, alert: "Export failed: #{e.message}"
  31. end
  32. # GET /admin/tools/export/download/:id
  33. def download
  34. export_job = ExportJob.find(params[:id])
  35. unless export_job.status == 'completed'
  36. redirect_to admin_export_path, alert: 'Export is not ready yet'
  37. return
  38. end
  39. unless File.exist?(export_job.file_path)
  40. redirect_to admin_export_path, alert: 'Export file not found'
  41. return
  42. end
  43. send_file export_job.file_path,
  44. filename: export_job.file_name,
  45. type: export_job.content_type || 'application/octet-stream',
  46. disposition: 'attachment'
  47. rescue => e
  48. Rails.logger.error("Export download error: #{e.message}")
  49. redirect_to admin_export_path, alert: "Download failed: #{e.message}"
  50. end
  51. end

app/controllers/admin/tools/export_personal_data_controller.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::ExportPersonalDataController < Admin::BaseController
  2. # GET /admin/tools/export_personal_data
  3. def index
  4. @export_requests = PersonalDataExportRequest.order(created_at: :desc).limit(50) rescue []
  5. end
  6. # POST /admin/tools/export_personal_data/request
  7. def request
  8. email = params[:email]
  9. unless email.present?
  10. redirect_to admin_export_personal_data_path, alert: 'Please provide an email address'
  11. return
  12. end
  13. user = User.find_by(email: email)
  14. unless user
  15. redirect_to admin_export_personal_data_path, alert: 'No user found with that email address'
  16. return
  17. end
  18. # Create export request
  19. export_request = PersonalDataExportRequest.create!(
  20. user_id: user.id,
  21. email: email,
  22. requested_by: current_user.id,
  23. status: 'pending',
  24. token: SecureRandom.hex(32)
  25. )
  26. # Queue the export job
  27. PersonalDataExportWorker.perform_async(export_request.id)
  28. redirect_to admin_export_personal_data_path,
  29. notice: "Personal data export request created for #{email}. Processing..."
  30. rescue => e
  31. Rails.logger.error("Personal data export error: #{e.message}")
  32. redirect_to admin_export_personal_data_path, alert: "Request failed: #{e.message}"
  33. end
  34. # GET /admin/tools/export_personal_data/download/:token
  35. def download
  36. export_request = PersonalDataExportRequest.find_by(token: params[:token])
  37. unless export_request
  38. redirect_to admin_export_personal_data_path, alert: 'Export request not found'
  39. return
  40. end
  41. unless export_request.status == 'completed'
  42. redirect_to admin_export_personal_data_path, alert: 'Export is not ready yet'
  43. return
  44. end
  45. unless File.exist?(export_request.file_path)
  46. redirect_to admin_export_personal_data_path, alert: 'Export file not found'
  47. return
  48. end
  49. send_file export_request.file_path,
  50. filename: "personal_data_#{export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
  51. type: 'application/json',
  52. disposition: 'attachment'
  53. rescue => e
  54. Rails.logger.error("Personal data download error: #{e.message}")
  55. redirect_to admin_export_personal_data_path, alert: "Download failed: #{e.message}"
  56. end
  57. end

app/controllers/admin/tools/import_controller.rb

0.0% lines covered

100.0% branches covered

53 relevant lines. 0 lines covered and 53 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::ImportController < Admin::BaseController
  2. # GET /admin/tools/import
  3. def index
  4. @import_jobs = ImportJob.order(created_at: :desc).limit(20) rescue []
  5. end
  6. # POST /admin/tools/import/upload
  7. def upload
  8. unless params[:file].present?
  9. redirect_to admin_import_path, alert: 'Please select a file to import'
  10. return
  11. end
  12. file = params[:file]
  13. import_type = params[:import_type] || 'wordpress'
  14. # Validate file type
  15. allowed_extensions = case import_type
  16. when 'wordpress' then ['.xml']
  17. when 'json' then ['.json']
  18. when 'csv' then ['.csv']
  19. else ['.xml', '.json', '.csv']
  20. end
  21. file_ext = File.extname(file.original_filename).downcase
  22. unless allowed_extensions.include?(file_ext)
  23. redirect_to admin_import_path, alert: "Invalid file type. Allowed: #{allowed_extensions.join(', ')}"
  24. return
  25. end
  26. # Store file temporarily
  27. temp_file = Tempfile.new(['import', file_ext])
  28. temp_file.binmode
  29. temp_file.write(file.read)
  30. temp_file.rewind
  31. # Create import job
  32. import_job = ImportJob.create!(
  33. import_type: import_type,
  34. file_path: temp_file.path,
  35. file_name: file.original_filename,
  36. user_id: current_user.id,
  37. status: 'pending',
  38. metadata: {
  39. file_size: file.size,
  40. content_type: file.content_type
  41. }
  42. )
  43. # Queue the import job
  44. ImportWorker.perform_async(import_job.id)
  45. redirect_to admin_import_path, notice: 'Import started. This may take a few minutes...'
  46. rescue => e
  47. Rails.logger.error("Import upload error: #{e.message}")
  48. redirect_to admin_import_path, alert: "Import failed: #{e.message}"
  49. end
  50. # POST /admin/tools/import/process
  51. def process_import
  52. import_job = ImportJob.find(params[:id])
  53. if import_job.status == 'completed'
  54. redirect_to admin_import_path, alert: 'This import has already been processed'
  55. return
  56. end
  57. ImportWorker.perform_async(import_job.id)
  58. redirect_to admin_import_path, notice: 'Import restarted'
  59. end
  60. end

app/controllers/admin/tools/shortcuts_controller.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::ShortcutsController < Admin::BaseController
  2. before_action :set_shortcut, only: [:show, :edit, :update, :destroy, :toggle]
  3. # GET /admin/tools/shortcuts
  4. def index
  5. @shortcuts = Shortcut.order(:category, :position)
  6. respond_to do |format|
  7. format.html
  8. format.json {
  9. render json: @shortcuts.active.order(:category, :position).map { |s| shortcut_json(s) }
  10. }
  11. end
  12. end
  13. # GET /admin/tools/shortcuts/:id
  14. def show
  15. end
  16. # GET /admin/tools/shortcuts/new
  17. def new
  18. @shortcut = Shortcut.new
  19. end
  20. # GET /admin/tools/shortcuts/:id/edit
  21. def edit
  22. end
  23. # POST /admin/tools/shortcuts
  24. def create
  25. @shortcut = Shortcut.new(shortcut_params)
  26. if @shortcut.save
  27. redirect_to admin_tools_shortcuts_path, notice: 'Shortcut created successfully.'
  28. else
  29. render :new, status: :unprocessable_entity
  30. end
  31. end
  32. # PATCH /admin/tools/shortcuts/:id
  33. def update
  34. if @shortcut.update(shortcut_params)
  35. redirect_to admin_tools_shortcuts_path, notice: 'Shortcut updated successfully.'
  36. else
  37. render :edit, status: :unprocessable_entity
  38. end
  39. end
  40. # DELETE /admin/tools/shortcuts/:id
  41. def destroy
  42. @shortcut.destroy
  43. redirect_to admin_tools_shortcuts_path, notice: 'Shortcut deleted successfully.'
  44. end
  45. # PATCH /admin/tools/shortcuts/:id/toggle
  46. def toggle
  47. @shortcut.update(active: !@shortcut.active)
  48. redirect_to admin_tools_shortcuts_path, notice: "Shortcut #{@shortcut.active? ? 'enabled' : 'disabled'}."
  49. end
  50. # POST /admin/tools/shortcuts/reorder
  51. def reorder
  52. params[:order].each_with_index do |id, index|
  53. Shortcut.find(id).update(position: index)
  54. end
  55. head :ok
  56. end
  57. private
  58. def set_shortcut
  59. @shortcut = Shortcut.find(params[:id])
  60. end
  61. def shortcut_params
  62. params.require(:shortcut).permit(
  63. :name, :description, :action_type, :action_value,
  64. :icon, :category, :position, :active
  65. )
  66. end
  67. def shortcut_json(shortcut)
  68. {
  69. id: shortcut.id,
  70. name: shortcut.name,
  71. description: shortcut.description,
  72. action_type: shortcut.action_type,
  73. action_value: shortcut.action_value,
  74. icon: shortcut.icon,
  75. category: shortcut.category
  76. }
  77. end
  78. end

app/controllers/admin/tools/site_health_controller.rb

0.0% lines covered

100.0% branches covered

185 relevant lines. 0 lines covered and 185 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::Tools::SiteHealthController < Admin::BaseController
  2. # GET /admin/tools/site_health
  3. def index
  4. @health_checks = run_health_checks
  5. end
  6. # POST /admin/tools/site_health/run_tests
  7. def run_tests
  8. @health_checks = run_health_checks
  9. render json: {
  10. status: overall_status(@health_checks),
  11. checks: @health_checks,
  12. timestamp: Time.current
  13. }
  14. end
  15. private
  16. def run_health_checks
  17. checks = []
  18. # Database Connection
  19. checks << check_database
  20. # Redis Connection
  21. checks << check_redis
  22. # File Permissions
  23. checks << check_file_permissions
  24. # Disk Space
  25. checks << check_disk_space
  26. # Ruby Version
  27. checks << check_ruby_version
  28. # Rails Version
  29. checks << check_rails_version
  30. # Required Gems
  31. checks << check_required_gems
  32. # ActiveStorage
  33. checks << check_active_storage
  34. # Mail Configuration
  35. checks << check_mail_config
  36. # Background Jobs
  37. checks << check_sidekiq
  38. # SSL/HTTPS
  39. checks << check_ssl
  40. # Performance
  41. checks << check_caching
  42. checks
  43. end
  44. def check_database
  45. {
  46. name: 'Database Connection',
  47. category: 'critical',
  48. status: ActiveRecord::Base.connection.active? ? 'pass' : 'fail',
  49. message: ActiveRecord::Base.connection.active? ?
  50. "Connected to #{ActiveRecord::Base.connection.adapter_name}" :
  51. 'Database connection failed',
  52. details: {
  53. adapter: ActiveRecord::Base.connection.adapter_name,
  54. database: ActiveRecord::Base.connection.current_database
  55. }
  56. }
  57. rescue => e
  58. { name: 'Database Connection', category: 'critical', status: 'fail', message: e.message }
  59. end
  60. def check_redis
  61. {
  62. name: 'Redis Connection',
  63. category: 'recommended',
  64. status: Redis.new.ping == 'PONG' ? 'pass' : 'fail',
  65. message: Redis.new.ping == 'PONG' ? 'Redis is running' : 'Redis connection failed',
  66. details: { url: ENV['REDIS_URL'] || 'redis://localhost:6379' }
  67. }
  68. rescue => e
  69. { name: 'Redis Connection', category: 'recommended', status: 'warning', message: "Redis not available: #{e.message}" }
  70. end
  71. def check_file_permissions
  72. writable_paths = ['tmp', 'log', 'storage', 'public/uploads']
  73. failed_paths = writable_paths.reject { |path| File.writable?(Rails.root.join(path)) }
  74. {
  75. name: 'File Permissions',
  76. category: 'critical',
  77. status: failed_paths.empty? ? 'pass' : 'fail',
  78. message: failed_paths.empty? ?
  79. 'All required directories are writable' :
  80. "Not writable: #{failed_paths.join(', ')}",
  81. details: { checked_paths: writable_paths }
  82. }
  83. end
  84. def check_disk_space
  85. stat = Sys::Filesystem.stat(Rails.root.to_s)
  86. free_gb = (stat.bytes_available / 1024.0 / 1024.0 / 1024.0).round(2)
  87. {
  88. name: 'Disk Space',
  89. category: 'recommended',
  90. status: free_gb > 1 ? 'pass' : 'warning',
  91. message: "#{free_gb} GB available",
  92. details: { free_gb: free_gb }
  93. }
  94. rescue => e
  95. { name: 'Disk Space', category: 'recommended', status: 'info', message: 'Could not check disk space' }
  96. end
  97. def check_ruby_version
  98. required_version = Gem::Version.new('3.0.0')
  99. current_version = Gem::Version.new(RUBY_VERSION)
  100. {
  101. name: 'Ruby Version',
  102. category: 'critical',
  103. status: current_version >= required_version ? 'pass' : 'fail',
  104. message: "Ruby #{RUBY_VERSION}",
  105. details: { required: '3.0.0+', current: RUBY_VERSION }
  106. }
  107. end
  108. def check_rails_version
  109. {
  110. name: 'Rails Version',
  111. category: 'info',
  112. status: 'pass',
  113. message: "Rails #{Rails.version}",
  114. details: { version: Rails.version }
  115. }
  116. end
  117. def check_required_gems
  118. required_gems = %w[devise pundit acts_as_tenant sidekiq flipper]
  119. missing = required_gems.reject { |gem| Gem.loaded_specs.key?(gem) }
  120. {
  121. name: 'Required Gems',
  122. category: 'critical',
  123. status: missing.empty? ? 'pass' : 'fail',
  124. message: missing.empty? ?
  125. 'All required gems are installed' :
  126. "Missing: #{missing.join(', ')}",
  127. details: { required: required_gems, missing: missing }
  128. }
  129. end
  130. def check_active_storage
  131. configured = Rails.application.config.active_storage.service.present?
  132. {
  133. name: 'ActiveStorage',
  134. category: 'recommended',
  135. status: configured ? 'pass' : 'warning',
  136. message: configured ?
  137. "Service: #{Rails.application.config.active_storage.service}" :
  138. 'ActiveStorage not fully configured',
  139. details: { service: Rails.application.config.active_storage.service }
  140. }
  141. end
  142. def check_mail_config
  143. configured = ActionMailer::Base.smtp_settings.present? ||
  144. ActionMailer::Base.delivery_method != :smtp
  145. {
  146. name: 'Email Configuration',
  147. category: 'recommended',
  148. status: configured ? 'pass' : 'warning',
  149. message: configured ?
  150. "Delivery method: #{ActionMailer::Base.delivery_method}" :
  151. 'Email not configured',
  152. details: { delivery_method: ActionMailer::Base.delivery_method }
  153. }
  154. end
  155. def check_sidekiq
  156. stats = Sidekiq::Stats.new
  157. {
  158. name: 'Background Jobs (Sidekiq)',
  159. category: 'recommended',
  160. status: 'pass',
  161. message: "#{stats.workers_size} workers, #{stats.enqueued} jobs queued",
  162. details: {
  163. workers: stats.workers_size,
  164. enqueued: stats.enqueued,
  165. processed: stats.processed,
  166. failed: stats.failed
  167. }
  168. }
  169. rescue => e
  170. { name: 'Background Jobs (Sidekiq)', category: 'recommended', status: 'warning', message: 'Sidekiq not running' }
  171. end
  172. def check_ssl
  173. {
  174. name: 'HTTPS/SSL',
  175. category: 'recommended',
  176. status: request.ssl? ? 'pass' : 'warning',
  177. message: request.ssl? ? 'HTTPS enabled' : 'Not using HTTPS',
  178. details: { protocol: request.protocol }
  179. }
  180. end
  181. def check_caching
  182. enabled = Rails.application.config.action_controller.perform_caching
  183. {
  184. name: 'Caching',
  185. category: 'performance',
  186. status: enabled ? 'pass' : 'info',
  187. message: enabled ? 'Caching enabled' : 'Caching disabled',
  188. details: {
  189. enabled: enabled,
  190. store: Rails.cache.class.name
  191. }
  192. }
  193. end
  194. def overall_status(checks)
  195. return 'fail' if checks.any? { |c| c[:category] == 'critical' && c[:status] == 'fail' }
  196. return 'warning' if checks.any? { |c| c[:status] == 'warning' }
  197. 'pass'
  198. end
  199. end

app/controllers/admin/trash_controller.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TrashController < Admin::BaseController
  2. before_action :ensure_admin
  3. def index
  4. @posts = Post.trashed.includes(:user, :content_type).order(deleted_at: :desc)
  5. @pages = Page.trashed.includes(:user).order(deleted_at: :desc)
  6. @media = Medium.trashed.includes(:user, :upload).order(deleted_at: :desc)
  7. @comments = Comment.trashed.includes(:user, :commentable).order(deleted_at: :desc)
  8. @stats = {
  9. posts: @posts.count,
  10. pages: @pages.count,
  11. media: @media.count,
  12. comments: @comments.count,
  13. total: @posts.count + @pages.count + @media.count + @comments.count
  14. }
  15. end
  16. def restore
  17. item = find_item
  18. item.untrash!
  19. flash[:notice] = "#{item.class.name} restored successfully"
  20. redirect_to admin_trash_index_path
  21. end
  22. def destroy_permanently
  23. item = find_item
  24. item.destroy_permanently!
  25. flash[:notice] = "#{item.class.name} permanently deleted"
  26. redirect_to admin_trash_index_path
  27. end
  28. def empty_trash
  29. Post.trashed.find_each(&:destroy_permanently!)
  30. Page.trashed.find_each(&:destroy_permanently!)
  31. Medium.trashed.find_each(&:destroy_permanently!)
  32. Comment.trashed.find_each(&:destroy_permanently!)
  33. flash[:notice] = "Trash emptied successfully"
  34. redirect_to admin_trash_index_path
  35. end
  36. private
  37. def find_item
  38. model_class = params[:type].constantize
  39. model_class.find(params[:id])
  40. end
  41. end

app/controllers/admin/trash_settings_controller.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::TrashSettingsController < Admin::BaseController
  2. before_action :ensure_admin
  3. before_action :set_trash_setting
  4. def show
  5. end
  6. def update
  7. if @trash_setting.update(trash_setting_params)
  8. flash[:notice] = "Trash settings updated successfully"
  9. redirect_to admin_trash_settings_path
  10. else
  11. flash[:alert] = "Failed to update trash settings"
  12. render :show
  13. end
  14. end
  15. def test_cleanup
  16. # Test the cleanup process without actually deleting anything
  17. threshold = @trash_setting.cleanup_threshold
  18. @test_results = {
  19. posts: Post.trashed.where('deleted_at < ?', threshold).count,
  20. pages: Page.trashed.where('deleted_at < ?', threshold).count,
  21. media: Medium.trashed.where('deleted_at < ?', threshold).count,
  22. comments: Comment.trashed.where('deleted_at < ?', threshold).count
  23. }
  24. @test_results[:total] = @test_results.values.sum
  25. render :show
  26. end
  27. def run_cleanup
  28. if @trash_setting.auto_cleanup_enabled?
  29. Post.cleanup_trash!
  30. Page.cleanup_trash!
  31. Medium.cleanup_trash!
  32. Comment.cleanup_trash!
  33. flash[:notice] = "Trash cleanup completed successfully"
  34. else
  35. flash[:alert] = "Automatic cleanup is disabled"
  36. end
  37. redirect_to admin_trash_settings_path
  38. end
  39. private
  40. def set_trash_setting
  41. @trash_setting = TrashSetting.current
  42. end
  43. def trash_setting_params
  44. params.require(:trash_setting).permit(:auto_cleanup_enabled, :cleanup_after_days)
  45. end
  46. end

app/controllers/admin/updates_controller.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::UpdatesController < Admin::BaseController
  2. def index
  3. @update_info = Railspress::UpdateChecker.check_for_updates
  4. @release_notes = Railspress::UpdateChecker.fetch_release_notes if @update_info[:update_available]
  5. end
  6. def check
  7. # Force a fresh check (bypass cache)
  8. Rails.cache.delete('railspress:update_check')
  9. @update_info = Railspress::UpdateChecker.check_for_updates
  10. if @update_info[:update_available]
  11. flash[:success] = "New version available: #{@update_info[:latest_version]}"
  12. else
  13. flash[:info] = "You're running the latest version (#{@update_info[:current_version]})"
  14. end
  15. redirect_to admin_updates_path
  16. end
  17. def release_notes
  18. @release_info = Railspress::UpdateChecker.fetch_release_notes
  19. if @release_info
  20. render json: @release_info
  21. else
  22. render json: { error: 'Could not fetch release notes' }, status: :unprocessable_entity
  23. end
  24. end
  25. end

app/controllers/admin/user_preferences_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::UserPreferencesController < Admin::BaseController
  2. def show
  3. render json: {
  4. sidebar_order: current_user.sidebar_order
  5. }
  6. end
  7. def update
  8. if params[:sidebar_order].present?
  9. current_user.update!(sidebar_order: params[:sidebar_order])
  10. render json: { status: 'success' }
  11. else
  12. render json: { status: 'error', message: 'No sidebar order provided' }, status: :unprocessable_entity
  13. end
  14. end
  15. end

app/controllers/admin/users_controller.rb

0.0% lines covered

100.0% branches covered

276 relevant lines. 0 lines covered and 276 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::UsersController < Admin::BaseController
  2. before_action :ensure_admin
  3. before_action :set_user, only: [:show, :edit, :update, :destroy]
  4. # GET /admin/users
  5. def index
  6. @users = User.all.order(created_at: :desc)
  7. respond_to do |format|
  8. format.html do
  9. @users_data = users_json
  10. @stats = {
  11. total: User.count,
  12. administrator: User.administrator.count,
  13. editor: User.editor.count,
  14. author: User.author.count
  15. }
  16. @bulk_actions = [
  17. { value: 'delete', label: 'Delete Selected' },
  18. { value: 'change_role', label: 'Change Role' }
  19. ]
  20. @role_options = [
  21. { value: 'administrator', label: 'Administrator' },
  22. { value: 'editor', label: 'Editor' },
  23. { value: 'author', label: 'Author' },
  24. { value: 'contributor', label: 'Contributor' },
  25. { value: 'subscriber', label: 'Subscriber' }
  26. ]
  27. @columns = [
  28. {
  29. title: "",
  30. formatter: "rowSelection",
  31. titleFormatter: "rowSelection",
  32. width: 50,
  33. headerSort: false
  34. },
  35. {
  36. title: "Name",
  37. field: "name",
  38. minWidth: 150,
  39. formatter: "html",
  40. formatterParams: {
  41. target: "_self"
  42. },
  43. cellClick: "function(e, cell) { const data = cell.getRow().getData(); window.location.href = data.edit_url; }"
  44. },
  45. {
  46. title: "Email",
  47. field: "email",
  48. minWidth: 200
  49. },
  50. {
  51. title: "Role",
  52. field: "role_badge",
  53. width: 130,
  54. formatter: "html",
  55. hozAlign: "center"
  56. },
  57. {
  58. title: "Posts",
  59. field: "posts_count",
  60. width: 80,
  61. hozAlign: "center"
  62. },
  63. {
  64. title: "Pages",
  65. field: "pages_count",
  66. width: 80,
  67. hozAlign: "center"
  68. },
  69. {
  70. title: "Last Updated",
  71. field: "last_sign_in",
  72. width: 150
  73. },
  74. {
  75. title: "Joined",
  76. field: "created_at",
  77. width: 120
  78. },
  79. {
  80. title: "Actions",
  81. field: "actions",
  82. width: 120,
  83. headerSort: false,
  84. formatter: "html",
  85. hozAlign: "center"
  86. }
  87. ]
  88. end
  89. format.json { render json: users_json }
  90. end
  91. end
  92. # GET /admin/users/1
  93. def show
  94. end
  95. # GET /admin/users/new
  96. def new
  97. @user = User.new
  98. end
  99. # GET /admin/users/1/edit
  100. def edit
  101. end
  102. # POST /admin/users
  103. def create
  104. @user = User.new(user_params)
  105. # Set password if provided
  106. if params[:user][:password].present?
  107. @user.password = params[:user][:password]
  108. @user.password_confirmation = params[:user][:password_confirmation]
  109. end
  110. if @user.save
  111. redirect_to admin_users_path, notice: 'User was successfully created.'
  112. else
  113. render :new, status: :unprocessable_entity
  114. end
  115. end
  116. # PATCH/PUT /admin/users/1
  117. def update
  118. user_update_params = user_params
  119. # Remove password params if not provided
  120. if params[:user][:password].blank?
  121. user_update_params = user_update_params.except(:password, :password_confirmation)
  122. end
  123. if @user.update(user_update_params)
  124. redirect_to admin_users_path, notice: 'User was successfully updated.'
  125. else
  126. render :edit, status: :unprocessable_entity
  127. end
  128. end
  129. # DELETE /admin/users/1
  130. def destroy
  131. if @user.id == current_user.id
  132. redirect_to admin_users_path, alert: 'You cannot delete your own account.'
  133. return
  134. end
  135. if @user.posts.any? || @user.pages.any?
  136. redirect_to admin_users_path, alert: 'Cannot delete user with existing content. Reassign content first.'
  137. return
  138. end
  139. @user.destroy
  140. redirect_to admin_users_path, notice: 'User was successfully deleted.'
  141. end
  142. # PATCH /admin/users/update_monaco_theme
  143. def update_monaco_theme
  144. if current_user.update(monaco_theme: params[:monaco_theme])
  145. render json: { success: true, theme: current_user.monaco_theme }
  146. else
  147. render json: { success: false, errors: current_user.errors.full_messages }
  148. end
  149. end
  150. # POST /admin/users/regenerate_api_key
  151. def regenerate_api_key
  152. @user = User.find(params[:id])
  153. @user.regenerate_api_token!
  154. redirect_to admin_users_path, notice: "API key regenerated successfully for #{@user.name}"
  155. end
  156. # POST /admin/users/bulk_action
  157. def bulk_action
  158. action = params[:bulk_action]
  159. user_ids = params[:user_ids] || []
  160. case action
  161. when 'delete'
  162. bulk_delete(user_ids)
  163. when 'activate'
  164. bulk_activate(user_ids)
  165. when 'deactivate'
  166. bulk_deactivate(user_ids)
  167. when 'change_role'
  168. bulk_change_role(user_ids, params[:role])
  169. else
  170. redirect_to admin_users_path, alert: 'Invalid bulk action.'
  171. end
  172. end
  173. # GET /admin/users/profile (current user profile)
  174. def profile
  175. @user = current_user
  176. render :edit
  177. end
  178. # PATCH /admin/users/update_profile
  179. def update_profile
  180. @user = current_user
  181. user_update_params = user_params.except(:role) # Can't change own role
  182. # Remove password params if not provided
  183. if params[:user][:password].blank?
  184. user_update_params = user_update_params.except(:password, :password_confirmation)
  185. end
  186. if @user.update(user_update_params)
  187. redirect_to admin_users_path, notice: 'Profile updated successfully.'
  188. else
  189. render :edit, status: :unprocessable_entity
  190. end
  191. end
  192. private
  193. def set_user
  194. @user = User.find(params[:id])
  195. end
  196. def user_params
  197. params.require(:user).permit(
  198. :email,
  199. :name,
  200. :role,
  201. :password,
  202. :password_confirmation,
  203. :avatar
  204. )
  205. end
  206. def users_json
  207. users = User.all.order(created_at: :desc)
  208. # Apply filters
  209. if params[:role].present?
  210. users = users.where(role: params[:role])
  211. end
  212. if params[:search].present?
  213. search_term = "%#{params[:search]}%"
  214. users = users.where(
  215. 'email LIKE ? OR name LIKE ?',
  216. search_term,
  217. search_term
  218. )
  219. end
  220. users.map do |user|
  221. {
  222. id: user.id,
  223. email: user.email,
  224. name: "<a href='#{edit_admin_user_path(user)}' style='color: #6366f1 !important; text-decoration: none !important; font-weight: 500 !important;'>#{user.name || 'N/A'}</a>",
  225. role: user.role.titleize,
  226. role_badge: role_badge(user.role),
  227. posts_count: user.posts.count,
  228. pages_count: user.pages.count,
  229. created_at: user.created_at.strftime('%b %d, %Y'),
  230. last_sign_in: user.updated_at.strftime('%b %d, %Y %H:%M'),
  231. edit_url: edit_admin_user_path(user),
  232. show_url: admin_user_path(user),
  233. delete_url: admin_user_path(user),
  234. actions: user_actions_html(user)
  235. }
  236. end
  237. end
  238. def role_badge(role)
  239. colors = {
  240. 'administrator' => 'bg-red-500',
  241. 'editor' => 'bg-blue-500',
  242. 'author' => 'bg-green-500',
  243. 'contributor' => 'bg-yellow-500',
  244. 'subscriber' => 'bg-gray-500'
  245. }
  246. color = colors[role] || 'bg-gray-500'
  247. "<span class='px-2 py-1 #{color} text-white text-xs rounded-full'>#{role.titleize}</span>"
  248. end
  249. def user_actions(user)
  250. actions = []
  251. actions << { label: 'Edit', url: edit_admin_user_path(user), class: 'text-blue-600' }
  252. actions << { label: 'View', url: admin_user_path(user), class: 'text-gray-600' }
  253. actions << { label: 'Delete', url: admin_user_path(user), method: 'delete', class: 'text-red-600' } unless user.id == current_user.id
  254. actions
  255. end
  256. def user_actions_html(user)
  257. actions_html = []
  258. actions_html << "<a href=\"#{edit_admin_user_path(user)}\" class=\"text-blue-600 hover:text-blue-900 mr-2\" title=\"Edit\"><svg class=\"w-4 h-4 inline\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"></path></svg></a>"
  259. actions_html << "<a href=\"#{admin_user_path(user)}\" class=\"text-gray-600 hover:text-gray-900 mr-2\" title=\"View\"><svg class=\"w-4 h-4 inline\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"></path></svg></a>"
  260. unless user.id == current_user.id
  261. actions_html << "<a href=\"#{admin_user_path(user)}\" data-turbo-method=\"delete\" data-turbo-confirm=\"Are you sure?\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\"><svg class=\"w-4 h-4 inline\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"></path></svg></a>"
  262. end
  263. actions_html.join('')
  264. end
  265. def bulk_delete(user_ids)
  266. # Don't delete current user
  267. user_ids = user_ids.reject { |id| id.to_i == current_user.id }
  268. # Don't delete users with content
  269. users_with_content = User.where(id: user_ids).select { |u| u.posts.any? || u.pages.any? }
  270. if users_with_content.any?
  271. redirect_to admin_users_path, alert: "Cannot delete #{users_with_content.count} user(s) with existing content."
  272. return
  273. end
  274. count = User.where(id: user_ids).destroy_all.count
  275. redirect_to admin_users_path, notice: "#{count} user(s) deleted successfully."
  276. end
  277. def bulk_activate(user_ids)
  278. # Implementation depends on if you have an 'active' field
  279. redirect_to admin_users_path, notice: "Users activated."
  280. end
  281. def bulk_deactivate(user_ids)
  282. # Implementation depends on if you have an 'active' field
  283. redirect_to admin_users_path, notice: "Users deactivated."
  284. end
  285. def bulk_change_role(user_ids, new_role)
  286. return unless User.roles.keys.include?(new_role)
  287. # Don't change current user's role
  288. user_ids = user_ids.reject { |id| id.to_i == current_user.id }
  289. count = User.where(id: user_ids).update_all(role: new_role)
  290. redirect_to admin_users_path, notice: "#{count} user(s) role changed to #{new_role.titleize}."
  291. end
  292. def ensure_admin
  293. unless current_user&.administrator?
  294. redirect_to admin_root_path, alert: 'Access denied. Administrator privileges required.'
  295. end
  296. end
  297. end

app/controllers/admin/webhooks_controller.rb

0.0% lines covered

100.0% branches covered

199 relevant lines. 0 lines covered and 199 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::WebhooksController < Admin::BaseController
  2. before_action :set_webhook, only: [:show, :edit, :update, :destroy, :test, :toggle_active]
  3. def index
  4. @webhooks = Webhook.order(created_at: :desc)
  5. # Filter by active status if specified
  6. if params[:show_inactive] != 'true'
  7. @webhooks = @webhooks.where(active: true)
  8. end
  9. @recent_deliveries = WebhookDelivery.includes(:webhook).recent.limit(20)
  10. # Prepare data for Tabulator
  11. @webhooks_data = webhooks_json
  12. # Define columns for Tabulator
  13. @columns = [
  14. {
  15. title: "",
  16. formatter: "rowSelection",
  17. titleFormatter: "rowSelection",
  18. width: 40,
  19. headerSort: false
  20. },
  21. {
  22. title: "Name & URL",
  23. field: "title",
  24. width: 300,
  25. formatter: "html"
  26. },
  27. {
  28. title: "Events",
  29. field: "events",
  30. width: 200,
  31. formatter: "html"
  32. },
  33. {
  34. title: "Status",
  35. field: "status",
  36. width: 120,
  37. formatter: "html"
  38. },
  39. {
  40. title: "Stats",
  41. field: "stats",
  42. width: 120,
  43. formatter: "html"
  44. },
  45. {
  46. title: "Success Rate",
  47. field: "success_rate",
  48. width: 100,
  49. formatter: "progress",
  50. formatterParams: {
  51. color: ["red", "orange", "green"],
  52. min: 0,
  53. max: 100
  54. }
  55. },
  56. {
  57. title: "Created",
  58. field: "created_at",
  59. width: 120,
  60. formatter: "datetime",
  61. formatterParams: {
  62. inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
  63. outputFormat: "MMM DD, YYYY"
  64. }
  65. },
  66. {
  67. title: "Actions",
  68. field: "actions",
  69. width: 200,
  70. formatter: "html",
  71. headerSort: false
  72. }
  73. ]
  74. # Define bulk actions
  75. @bulk_actions = [
  76. { value: "activate", label: "Activate" },
  77. { value: "deactivate", label: "Deactivate" },
  78. { value: "delete", label: "Delete" }
  79. ]
  80. # Stats for the cards (using the format expected by the stats_cards partial)
  81. @stats = {
  82. total: @webhooks.count,
  83. active: @webhooks.where(active: true).count,
  84. deliveries: @webhooks.sum(:total_deliveries),
  85. failed: @webhooks.sum(:failed_deliveries)
  86. }
  87. end
  88. def show
  89. @deliveries = @webhook.webhook_deliveries.recent.page(params[:page]).per(20)
  90. end
  91. def new
  92. @webhook = Webhook.new
  93. end
  94. def create
  95. @webhook = Webhook.new(webhook_params)
  96. if @webhook.save
  97. redirect_to admin_webhook_path(@webhook), notice: 'Webhook created successfully.'
  98. else
  99. render :new
  100. end
  101. end
  102. def edit
  103. end
  104. def update
  105. if @webhook.update(webhook_params)
  106. redirect_to admin_webhook_path(@webhook), notice: 'Webhook updated successfully.'
  107. else
  108. render :edit
  109. end
  110. end
  111. def destroy
  112. @webhook.destroy
  113. redirect_to admin_webhooks_path, notice: 'Webhook deleted successfully.'
  114. end
  115. def toggle_active
  116. @webhook.update!(active: !@webhook.active?)
  117. status = @webhook.active? ? 'activated' : 'deactivated'
  118. redirect_to admin_webhooks_path, notice: "Webhook #{status}."
  119. end
  120. def test
  121. # Send a test webhook
  122. test_payload = {
  123. message: 'This is a test webhook from RailsPress',
  124. timestamp: Time.current.iso8601
  125. }
  126. delivery = @webhook.deliver('test.webhook', test_payload)
  127. redirect_to admin_webhook_path(@webhook), notice: "Test webhook queued for delivery. Check delivery status below."
  128. end
  129. def bulk_action
  130. webhook_ids = params[:webhook_ids]
  131. action = params[:bulk_action]
  132. return redirect_to admin_webhooks_path, alert: "No webhooks selected." if webhook_ids.blank?
  133. webhooks = Webhook.where(id: webhook_ids)
  134. case action
  135. when 'activate'
  136. webhooks.update_all(active: true)
  137. redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) activated."
  138. when 'deactivate'
  139. webhooks.update_all(active: false)
  140. redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) deactivated."
  141. when 'delete'
  142. webhooks.destroy_all
  143. redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) deleted."
  144. else
  145. redirect_to admin_webhooks_path, alert: "Invalid bulk action."
  146. end
  147. end
  148. private
  149. def set_webhook
  150. @webhook = Webhook.find(params[:id])
  151. end
  152. def webhook_params
  153. params.require(:webhook).permit(
  154. :name,
  155. :description,
  156. :url,
  157. :active,
  158. :retry_limit,
  159. :timeout,
  160. events: []
  161. )
  162. end
  163. def webhooks_json
  164. @webhooks.map do |webhook|
  165. {
  166. id: webhook.id,
  167. title: "<a href=\"#{admin_webhook_path(webhook)}\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">#{webhook.name}</a><br><small class=\"text-gray-500 font-mono\">#{webhook.url}</small>",
  168. name: webhook.name, # For search functionality
  169. events: format_events(webhook.events),
  170. status: format_status(webhook),
  171. stats: "<div><strong>#{webhook.total_deliveries}</strong> total<br><small class=\"text-gray-500\">#{webhook.failed_deliveries} failed</small></div>",
  172. success_rate: webhook.success_rate,
  173. created_at: webhook.created_at.iso8601,
  174. actions: format_actions(webhook),
  175. edit_url: edit_admin_webhook_path(webhook),
  176. show_url: admin_webhook_path(webhook)
  177. }
  178. end
  179. end
  180. def format_events(events)
  181. events.map { |event| "<span class=\"inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200\">#{event}</span>" }.join(' ')
  182. end
  183. def format_status(webhook)
  184. status_html = if webhook.active?
  185. '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"><svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3"/></svg>Active</span>'
  186. else
  187. '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">Inactive</span>'
  188. end
  189. # Add unhealthy indicator if needed
  190. unless webhook.healthy?
  191. status_html += '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 ml-2">Unhealthy</span>'
  192. end
  193. status_html
  194. end
  195. def format_actions(webhook)
  196. %{
  197. <div class="flex space-x-2">
  198. <a href="#{admin_webhook_path(webhook)}" class="text-indigo-600 hover:text-indigo-900">View</a>
  199. <a href="#{edit_admin_webhook_path(webhook)}" class="text-indigo-600 hover:text-indigo-900">Edit</a>
  200. <a href="#{test_admin_webhook_path(webhook)}" data-method="post" class="text-green-600 hover:text-green-900">Test</a>
  201. <a href="#{toggle_active_admin_webhook_path(webhook)}" data-method="patch" class="text-yellow-600 hover:text-yellow-900">#{webhook.active? ? 'Disable' : 'Enable'}</a>
  202. <a href="#{admin_webhook_path(webhook)}" data-method="delete" data-confirm="Are you sure?" class="text-red-600 hover:text-red-900">Delete</a>
  203. </div>
  204. }
  205. end
  206. end

app/controllers/admin/widgets_controller.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Admin::WidgetsController < Admin::BaseController
  2. before_action :set_widget, only: %i[ show edit update destroy ]
  3. # GET /admin/widgets or /admin/widgets.json
  4. def index
  5. @widgets = Widget.all
  6. end
  7. # GET /admin/widgets/1 or /admin/widgets/1.json
  8. def show
  9. end
  10. # GET /admin/widgets/new
  11. def new
  12. @widget = Widget.new
  13. end
  14. # GET /admin/widgets/1/edit
  15. def edit
  16. end
  17. # POST /admin/widgets or /admin/widgets.json
  18. def create
  19. @widget = Widget.new(widget_params)
  20. respond_to do |format|
  21. if @widget.save
  22. format.html { redirect_to [:admin, @widget], notice: "Widget was successfully created." }
  23. format.json { render :show, status: :created, location: @widget }
  24. else
  25. format.html { render :new, status: :unprocessable_entity }
  26. format.json { render json: @widget.errors, status: :unprocessable_entity }
  27. end
  28. end
  29. end
  30. # PATCH/PUT /admin/widgets/1 or /admin/widgets/1.json
  31. def update
  32. respond_to do |format|
  33. if @widget.update(widget_params)
  34. format.html { redirect_to [:admin, @widget], notice: "Widget was successfully updated.", status: :see_other }
  35. format.json { render :show, status: :ok, location: @widget }
  36. else
  37. format.html { render :edit, status: :unprocessable_entity }
  38. format.json { render json: @widget.errors, status: :unprocessable_entity }
  39. end
  40. end
  41. end
  42. # DELETE /admin/widgets/1 or /admin/widgets/1.json
  43. def destroy
  44. @widget.destroy!
  45. respond_to do |format|
  46. format.html { redirect_to admin_widgets_path, notice: "Widget was successfully destroyed.", status: :see_other }
  47. format.json { head :no_content }
  48. end
  49. end
  50. private
  51. # Use callbacks to share common setup or constraints between actions.
  52. def set_widget
  53. @widget = Widget.find(params[:id])
  54. end
  55. # Only allow a list of trusted parameters through.
  56. def widget_params
  57. params.fetch(:widget, {})
  58. end
  59. end

app/controllers/analytics_controller.rb

0.0% lines covered

100.0% branches covered

77 relevant lines. 0 lines covered and 77 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsController < ApplicationController
  2. skip_before_action :authenticate_user!, only: [:track, :duration]
  3. skip_before_action :verify_authenticity_token, only: [:track, :duration]
  4. # POST /analytics/track
  5. def track
  6. # Extract data from request
  7. data = JSON.parse(request.body.read) rescue {}
  8. # Track the pageview
  9. Pageview.track(request, {
  10. title: data['title'],
  11. user_id: current_user&.id,
  12. session_id: data['session_id'] || cookies['_railspress_session_id'],
  13. consented: data['consented'] || false,
  14. metadata: {
  15. screen_width: data['screen_width'],
  16. screen_height: data['screen_height'],
  17. viewport_width: data['viewport_width'],
  18. viewport_height: data['viewport_height'],
  19. language: data['language'],
  20. timezone: data['timezone']
  21. }
  22. })
  23. # Generate and set session cookie if not exists
  24. unless cookies['_railspress_session_id']
  25. cookies['_railspress_session_id'] = {
  26. value: SecureRandom.hex(16),
  27. expires: 30.days.from_now,
  28. httponly: true,
  29. same_site: :lax
  30. }
  31. end
  32. head :ok
  33. rescue => e
  34. Rails.logger.error "Analytics tracking error: #{e.message}"
  35. head :ok # Always return OK to not break user experience
  36. end
  37. # POST /analytics/duration
  38. def duration
  39. data = JSON.parse(request.body.read) rescue {}
  40. # Find recent pageview for this path and session
  41. session_id = cookies['_railspress_session_id']
  42. pageview = Pageview.where(
  43. path: data['path'],
  44. session_id: session_id
  45. ).where('visited_at >= ?', 10.minutes.ago).last
  46. if pageview
  47. pageview.update(duration: data['duration'])
  48. end
  49. head :ok
  50. rescue => e
  51. Rails.logger.error "Duration tracking error: #{e.message}"
  52. head :ok
  53. end
  54. # POST /analytics/reading
  55. def reading
  56. data = JSON.parse(request.body.read) rescue {}
  57. # Find recent pageview for this path and session
  58. session_id = data['session_id'] || cookies['_railspress_session_id']
  59. pageview = Pageview.where(
  60. path: data['path'],
  61. session_id: session_id
  62. ).where('visited_at >= ?', 10.minutes.ago).last
  63. if pageview
  64. pageview.update(
  65. reading_time: data['reading_time'],
  66. scroll_depth: data['scroll_depth'],
  67. completion_rate: data['completion_rate'],
  68. time_on_page: data['reading_time'],
  69. exit_intent: data['exit_intent'],
  70. is_reader: data['is_reader'] || false,
  71. engagement_score: data['engagement_score'] || 0
  72. )
  73. end
  74. head :ok
  75. rescue => e
  76. Rails.logger.error "Reading tracking error: #{e.message}"
  77. head :ok
  78. end
  79. def api_docs
  80. render 'analytics_api_documentation', layout: false
  81. end
  82. def examples
  83. render 'examples/analytics_plugin_example', layout: false
  84. end
  85. end

app/controllers/api/v1/ai_agents_controller.rb

0.0% lines covered

100.0% branches covered

170 relevant lines. 0 lines covered and 170 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::AiAgentsController < ApplicationController
  2. before_action :authenticate_api_user!
  3. before_action :set_agent, only: [:execute, :show, :update, :destroy]
  4. # GET /api/v1/ai_agents
  5. def index
  6. agents = AiAgent.active.ordered.includes(:ai_provider)
  7. render json: {
  8. success: true,
  9. agents: agents.map { |agent| agent_json(agent) },
  10. total: agents.count
  11. }
  12. end
  13. # GET /api/v1/ai_agents/:id
  14. def show
  15. render json: {
  16. success: true,
  17. agent: agent_json(@agent, detailed: true)
  18. }
  19. end
  20. # POST /api/v1/ai_agents
  21. def create
  22. provider = AiProvider.find(params[:ai_provider_id])
  23. agent = AiAgent.new(agent_params.merge(ai_provider: provider))
  24. if agent.save
  25. render json: {
  26. success: true,
  27. agent: agent_json(agent, detailed: true),
  28. message: 'AI Agent created successfully'
  29. }, status: :created
  30. else
  31. render json: {
  32. success: false,
  33. errors: agent.errors.full_messages
  34. }, status: :unprocessable_entity
  35. end
  36. end
  37. # PATCH/PUT /api/v1/ai_agents/:id
  38. def update
  39. if @agent.update(agent_params)
  40. render json: {
  41. success: true,
  42. agent: agent_json(@agent, detailed: true),
  43. message: 'AI Agent updated successfully'
  44. }
  45. else
  46. render json: {
  47. success: false,
  48. errors: @agent.errors.full_messages
  49. }, status: :unprocessable_entity
  50. end
  51. end
  52. # DELETE /api/v1/ai_agents/:id
  53. def destroy
  54. if @agent.destroy
  55. render json: {
  56. success: true,
  57. message: 'AI Agent deleted successfully'
  58. }
  59. else
  60. render json: {
  61. success: false,
  62. error: 'Failed to delete agent'
  63. }, status: :unprocessable_entity
  64. end
  65. end
  66. # POST /api/v1/ai_agents/execute
  67. def execute
  68. user_input = params[:user_input] || ""
  69. context = params[:context] || {}
  70. begin
  71. result = @agent.execute(user_input, context)
  72. render json: {
  73. success: true,
  74. result: result,
  75. agent: {
  76. id: @agent.id,
  77. name: @agent.name,
  78. type: @agent.agent_type
  79. }
  80. }
  81. rescue => e
  82. render json: {
  83. success: false,
  84. error: e.message
  85. }, status: :unprocessable_entity
  86. end
  87. end
  88. # POST /api/v1/ai_agents/execute/:agent_type
  89. def execute_by_type
  90. agent_type = params[:agent_type]
  91. user_input = params[:user_input] || ""
  92. context = params[:context] || {}
  93. agent = AiAgent.active.find_by(agent_type: agent_type)
  94. unless agent
  95. render json: {
  96. success: false,
  97. error: "No active agent found for type: #{agent_type}"
  98. }, status: :not_found
  99. return
  100. end
  101. begin
  102. result = agent.execute(user_input, context)
  103. render json: {
  104. success: true,
  105. result: result,
  106. agent: {
  107. id: agent.id,
  108. name: agent.name,
  109. type: agent.agent_type
  110. }
  111. }
  112. rescue => e
  113. render json: {
  114. success: false,
  115. error: e.message
  116. }, status: :unprocessable_entity
  117. end
  118. end
  119. private
  120. def set_agent
  121. @agent = AiAgent.active.find(params[:id])
  122. rescue ActiveRecord::RecordNotFound
  123. render json: {
  124. success: false,
  125. error: "Agent not found or inactive"
  126. }, status: :not_found
  127. end
  128. def agent_params
  129. params.require(:ai_agent).permit(
  130. :name,
  131. :agent_type,
  132. :prompt,
  133. :content,
  134. :guidelines,
  135. :rules,
  136. :tasks,
  137. :master_prompt,
  138. :active,
  139. :position
  140. )
  141. end
  142. def agent_json(agent, detailed: false)
  143. json = {
  144. id: agent.id,
  145. name: agent.name,
  146. type: agent.agent_type,
  147. active: agent.active,
  148. provider: {
  149. id: agent.ai_provider_id,
  150. name: agent.ai_provider.name,
  151. type: agent.ai_provider.provider_type
  152. }
  153. }
  154. if detailed
  155. json.merge!({
  156. prompt: agent.prompt,
  157. content: agent.content,
  158. guidelines: agent.guidelines,
  159. rules: agent.rules,
  160. tasks: agent.tasks,
  161. master_prompt: agent.master_prompt,
  162. position: agent.position,
  163. created_at: agent.created_at,
  164. updated_at: agent.updated_at
  165. })
  166. end
  167. json
  168. end
  169. def authenticate_api_user!
  170. # For now, we'll allow any authenticated user
  171. # You can add more specific authentication logic here
  172. unless current_user
  173. render json: {
  174. success: false,
  175. error: "Authentication required"
  176. }, status: :unauthorized
  177. end
  178. end
  179. end

app/controllers/api/v1/ai_providers_controller.rb

0.0% lines covered

100.0% branches covered

135 relevant lines. 0 lines covered and 135 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::AiProvidersController < ApplicationController
  2. before_action :authenticate_api_user!
  3. before_action :require_admin!, except: [:index, :show]
  4. before_action :set_provider, only: [:show, :update, :destroy, :toggle]
  5. # GET /api/v1/ai_providers
  6. def index
  7. providers = AiProvider.ordered.all
  8. render json: {
  9. success: true,
  10. providers: providers.map { |p| provider_json(p) },
  11. total: providers.count
  12. }
  13. end
  14. # GET /api/v1/ai_providers/:id
  15. def show
  16. render json: {
  17. success: true,
  18. provider: provider_json(@provider, detailed: true)
  19. }
  20. end
  21. # POST /api/v1/ai_providers
  22. def create
  23. provider = AiProvider.new(provider_params)
  24. if provider.save
  25. render json: {
  26. success: true,
  27. provider: provider_json(provider, detailed: true),
  28. message: 'AI Provider created successfully'
  29. }, status: :created
  30. else
  31. render json: {
  32. success: false,
  33. errors: provider.errors.full_messages
  34. }, status: :unprocessable_entity
  35. end
  36. end
  37. # PATCH/PUT /api/v1/ai_providers/:id
  38. def update
  39. if @provider.update(provider_params)
  40. render json: {
  41. success: true,
  42. provider: provider_json(@provider, detailed: true),
  43. message: 'AI Provider updated successfully'
  44. }
  45. else
  46. render json: {
  47. success: false,
  48. errors: @provider.errors.full_messages
  49. }, status: :unprocessable_entity
  50. end
  51. end
  52. # DELETE /api/v1/ai_providers/:id
  53. def destroy
  54. if @provider.ai_agents.any?
  55. render json: {
  56. success: false,
  57. error: 'Cannot delete provider with active agents'
  58. }, status: :unprocessable_entity
  59. return
  60. end
  61. if @provider.destroy
  62. render json: {
  63. success: true,
  64. message: 'AI Provider deleted successfully'
  65. }
  66. else
  67. render json: {
  68. success: false,
  69. error: 'Failed to delete provider'
  70. }, status: :unprocessable_entity
  71. end
  72. end
  73. # PATCH /api/v1/ai_providers/:id/toggle
  74. def toggle
  75. @provider.update!(active: !@provider.active)
  76. render json: {
  77. success: true,
  78. provider: provider_json(@provider),
  79. message: "Provider #{@provider.active ? 'activated' : 'deactivated'}"
  80. }
  81. end
  82. private
  83. def set_provider
  84. @provider = AiProvider.find(params[:id])
  85. rescue ActiveRecord::RecordNotFound
  86. render json: {
  87. success: false,
  88. error: "Provider not found"
  89. }, status: :not_found
  90. end
  91. def provider_params
  92. params.require(:ai_provider).permit(
  93. :name,
  94. :provider_type,
  95. :api_key,
  96. :api_url,
  97. :model_identifier,
  98. :max_tokens,
  99. :temperature,
  100. :active,
  101. :position
  102. )
  103. end
  104. def provider_json(provider, detailed: false)
  105. json = {
  106. id: provider.id,
  107. name: provider.name,
  108. type: provider.provider_type,
  109. model: provider.model_identifier,
  110. active: provider.active
  111. }
  112. if detailed
  113. json.merge!({
  114. api_url: provider.api_url,
  115. max_tokens: provider.max_tokens,
  116. temperature: provider.temperature,
  117. position: provider.position,
  118. agents_count: provider.ai_agents.count,
  119. created_at: provider.created_at,
  120. updated_at: provider.updated_at
  121. })
  122. end
  123. json
  124. end
  125. def authenticate_api_user!
  126. unless current_user
  127. render json: {
  128. success: false,
  129. error: "Authentication required"
  130. }, status: :unauthorized
  131. end
  132. end
  133. def require_admin!
  134. unless current_user&.administrator?
  135. render json: {
  136. success: false,
  137. error: "Admin access required"
  138. }, status: :forbidden
  139. end
  140. end
  141. end

app/controllers/api/v1/ai_seo_controller.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::AiSeoController < Api::V1::BaseController
  2. # POST /api/v1/ai_seo/generate
  3. def generate
  4. content_type = params[:content_type] # 'post' or 'page'
  5. content_id = params[:content_id]
  6. unless content_type.present? && content_id.present?
  7. return render_error('Missing content_type or content_id', 400)
  8. end
  9. result = AiSeo.generate_seo(content_type, content_id)
  10. if result[:success]
  11. render_success(result, 'SEO generated successfully')
  12. else
  13. render_error(result[:error] || 'Failed to generate SEO', 422)
  14. end
  15. end
  16. # POST /api/v1/ai_seo/analyze
  17. def analyze
  18. content_text = params[:content]
  19. unless content_text.present?
  20. return render_error('Missing content parameter', 400)
  21. end
  22. plugin = Railspress::PluginSystem.get_plugin('ai_seo')
  23. unless plugin
  24. return render_error('AI SEO plugin not active', 503)
  25. end
  26. begin
  27. # Create a temporary object for analysis
  28. temp_object = OpenStruct.new(
  29. title: params[:title] || 'Untitled',
  30. content: OpenStruct.new(to_plain_text: content_text)
  31. )
  32. ai_response = plugin.send(:call_ai_api, content_text, temp_object)
  33. seo_data = plugin.send(:parse_ai_response, ai_response)
  34. render_success(seo_data, 'Content analyzed successfully')
  35. rescue => e
  36. render_error("Analysis failed: #{e.message}", 500)
  37. end
  38. end
  39. # GET /api/v1/ai_seo/status
  40. def status
  41. plugin = Railspress::PluginSystem.get_plugin('ai_seo')
  42. unless plugin
  43. return render json: {
  44. active: false,
  45. configured: false,
  46. message: 'AI SEO plugin not active'
  47. }
  48. end
  49. render json: {
  50. active: true,
  51. configured: plugin.enabled?,
  52. provider: plugin.get_setting('ai_provider'),
  53. model: plugin.get_setting('model'),
  54. auto_generate: plugin.get_setting('auto_generate_on_save'),
  55. rate_limit: {
  56. max_per_hour: plugin.get_setting('max_requests_per_hour'),
  57. current: plugin.send(:get_request_count)
  58. }
  59. }
  60. end
  61. # POST /api/v1/ai_seo/batch_generate
  62. def batch_generate
  63. content_type = params[:content_type]
  64. content_ids = params[:content_ids] # Array of IDs
  65. unless content_type.present? && content_ids.is_a?(Array)
  66. return render_error('Missing or invalid parameters', 400)
  67. end
  68. results = []
  69. content_ids.each do |content_id|
  70. result = AiSeo.generate_seo(content_type, content_id)
  71. results << {
  72. content_id: content_id,
  73. success: result[:success],
  74. data: result[:success] ? result : { error: result[:error] }
  75. }
  76. end
  77. render_success({
  78. total: results.count,
  79. successful: results.count { |r| r[:success] },
  80. failed: results.count { |r| !r[:success] },
  81. results: results
  82. }, 'Batch generation completed')
  83. end
  84. end

app/controllers/api/v1/analytics_controller.rb

0.0% lines covered

100.0% branches covered

190 relevant lines. 0 lines covered and 190 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Api::V1::AnalyticsController < Api::V1::BaseController
  3. before_action :authenticate_api_user!
  4. before_action :set_tenant
  5. # GET /api/v1/analytics/posts/:id
  6. def post_analytics
  7. post = Post.find(params[:id])
  8. analytics_data = ContentAnalyticsService.post_analytics(
  9. post.id,
  10. period: params[:period]&.to_sym || :month
  11. )
  12. render json: {
  13. success: true,
  14. data: {
  15. post: {
  16. id: post.id,
  17. title: post.title,
  18. slug: post.slug,
  19. published_at: post.published_at,
  20. status: post.status
  21. },
  22. analytics: analytics_data,
  23. period: params[:period] || 'month',
  24. generated_at: Time.current.iso8601
  25. }
  26. }
  27. rescue ActiveRecord::RecordNotFound
  28. render json: {
  29. success: false,
  30. error: 'Post not found',
  31. code: 'POST_NOT_FOUND'
  32. }, status: :not_found
  33. rescue => e
  34. Rails.logger.error "Post analytics API error: #{e.message}"
  35. render json: {
  36. success: false,
  37. error: 'Failed to fetch post analytics',
  38. code: 'ANALYTICS_ERROR'
  39. }, status: :internal_server_error
  40. end
  41. # GET /api/v1/analytics/pages/:id
  42. def page_analytics
  43. page = Page.find(params[:id])
  44. analytics_data = ContentAnalyticsService.page_analytics(
  45. page.id,
  46. period: params[:period]&.to_sym || :month
  47. )
  48. render json: {
  49. success: true,
  50. data: {
  51. page: {
  52. id: page.id,
  53. title: page.title,
  54. slug: page.slug,
  55. created_at: page.created_at,
  56. status: page.status
  57. },
  58. analytics: analytics_data,
  59. period: params[:period] || 'month',
  60. generated_at: Time.current.iso8601
  61. }
  62. }
  63. rescue ActiveRecord::RecordNotFound
  64. render json: {
  65. success: false,
  66. error: 'Page not found',
  67. code: 'PAGE_NOT_FOUND'
  68. }, status: :not_found
  69. rescue => e
  70. Rails.logger.error "Page analytics API error: #{e.message}"
  71. render json: {
  72. success: false,
  73. error: 'Failed to fetch page analytics',
  74. code: 'ANALYTICS_ERROR'
  75. }, status: :internal_server_error
  76. end
  77. # GET /api/v1/analytics/posts
  78. def posts_analytics
  79. period = params[:period]&.to_sym || :month
  80. limit = [params[:limit]&.to_i || 10, 100].min
  81. posts_data = ContentAnalyticsService.top_performing_content(
  82. content_type: 'posts',
  83. period: period,
  84. limit: limit
  85. )
  86. render json: {
  87. success: true,
  88. data: {
  89. posts: posts_data,
  90. period: period.to_s,
  91. limit: limit,
  92. generated_at: Time.current.iso8601
  93. }
  94. }
  95. rescue => e
  96. Rails.logger.error "Posts analytics API error: #{e.message}"
  97. render json: {
  98. success: false,
  99. error: 'Failed to fetch posts analytics',
  100. code: 'ANALYTICS_ERROR'
  101. }, status: :internal_server_error
  102. end
  103. # GET /api/v1/analytics/pages
  104. def pages_analytics
  105. period = params[:period]&.to_sym || :month
  106. limit = [params[:limit]&.to_i || 10, 100].min
  107. pages_data = ContentAnalyticsService.top_performing_content(
  108. content_type: 'pages',
  109. period: period,
  110. limit: limit
  111. )
  112. render json: {
  113. success: true,
  114. data: {
  115. pages: pages_data,
  116. period: period.to_s,
  117. limit: limit,
  118. generated_at: Time.current.iso8601
  119. }
  120. }
  121. rescue => e
  122. Rails.logger.error "Pages analytics API error: #{e.message}"
  123. render json: {
  124. success: false,
  125. error: 'Failed to fetch pages analytics',
  126. code: 'ANALYTICS_ERROR'
  127. }, status: :internal_server_error
  128. end
  129. # GET /api/v1/analytics/overview
  130. def overview
  131. period = params[:period]&.to_sym || :month
  132. overview_data = {
  133. total_pageviews: AnalyticsService.total_pageviews(period: period),
  134. unique_visitors: AnalyticsService.unique_visitors(period: period),
  135. top_posts: ContentAnalyticsService.top_performing_content(content_type: 'posts', period: period, limit: 5),
  136. top_pages: ContentAnalyticsService.top_performing_content(content_type: 'pages', period: period, limit: 5),
  137. traffic_sources: AnalyticsService.traffic_sources(period: period),
  138. audience_insights: AnalyticsService.audience_insights(period: period)
  139. }
  140. render json: {
  141. success: true,
  142. data: {
  143. overview: overview_data,
  144. period: period.to_s,
  145. generated_at: Time.current.iso8601
  146. }
  147. }
  148. rescue => e
  149. Rails.logger.error "Analytics overview API error: #{e.message}"
  150. render json: {
  151. success: false,
  152. error: 'Failed to fetch analytics overview',
  153. code: 'ANALYTICS_ERROR'
  154. }, status: :internal_server_error
  155. end
  156. # GET /api/v1/analytics/realtime
  157. def realtime
  158. realtime_data = AnalyticsService.realtime_stats
  159. render json: {
  160. success: true,
  161. data: {
  162. realtime: realtime_data,
  163. generated_at: Time.current.iso8601
  164. }
  165. }
  166. rescue => e
  167. Rails.logger.error "Realtime analytics API error: #{e.message}"
  168. render json: {
  169. success: false,
  170. error: 'Failed to fetch realtime analytics',
  171. code: 'ANALYTICS_ERROR'
  172. }, status: :internal_server_error
  173. end
  174. private
  175. def authenticate_api_user!
  176. # Check for API key authentication
  177. api_key = request.headers['Authorization']&.gsub(/^Bearer /, '') || params[:api_key]
  178. if api_key.blank?
  179. render json: {
  180. success: false,
  181. error: 'API key required',
  182. code: 'MISSING_API_KEY'
  183. }, status: :unauthorized
  184. return
  185. end
  186. @current_user = User.find_by(api_key: api_key)
  187. unless @current_user&.administrator?
  188. render json: {
  189. success: false,
  190. error: 'Invalid API key or insufficient permissions',
  191. code: 'INVALID_API_KEY'
  192. }, status: :unauthorized
  193. end
  194. end
  195. def set_tenant
  196. ActsAsTenant.current_tenant = @current_user&.tenant
  197. end
  198. end

app/controllers/api/v1/auth_controller.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class AuthController < ApplicationController
  4. skip_before_action :verify_authenticity_token
  5. # POST /api/v1/auth/login
  6. def login
  7. user = User.find_by(email: params[:email])
  8. if user&.valid_password?(params[:password])
  9. render json: {
  10. success: true,
  11. data: {
  12. user: {
  13. id: user.id,
  14. email: user.email,
  15. role: user.role
  16. },
  17. api_token: user.api_token,
  18. message: 'Login successful'
  19. }
  20. }, status: :ok
  21. else
  22. render json: {
  23. success: false,
  24. error: 'Invalid email or password'
  25. }, status: :unauthorized
  26. end
  27. end
  28. # POST /api/v1/auth/register
  29. def register
  30. user = User.new(registration_params)
  31. user.role = :subscriber # New users are subscribers by default
  32. if user.save
  33. render json: {
  34. success: true,
  35. data: {
  36. user: {
  37. id: user.id,
  38. email: user.email,
  39. role: user.role
  40. },
  41. api_token: user.api_token,
  42. message: 'Registration successful'
  43. }
  44. }, status: :created
  45. else
  46. render json: {
  47. success: false,
  48. error: user.errors.full_messages.join(', ')
  49. }, status: :unprocessable_entity
  50. end
  51. end
  52. # POST /api/v1/auth/validate
  53. def validate_token
  54. token = request.headers['Authorization']&.split(' ')&.last
  55. user = User.find_by(api_token: token)
  56. if user
  57. render json: {
  58. success: true,
  59. data: {
  60. valid: true,
  61. user: {
  62. id: user.id,
  63. email: user.email,
  64. role: user.role
  65. }
  66. }
  67. }
  68. else
  69. render json: {
  70. success: false,
  71. data: { valid: false },
  72. error: 'Invalid token'
  73. }, status: :unauthorized
  74. end
  75. end
  76. private
  77. def registration_params
  78. params.permit(:email, :password, :password_confirmation)
  79. end
  80. end
  81. end
  82. end

app/controllers/api/v1/base_controller.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::BaseController < ActionController::API
  2. # Skip Devise authentication
  3. skip_before_action :authenticate_user! if respond_to?(:authenticate_user!)
  4. # Set content type to JSON
  5. before_action :set_content_type
  6. # This will be overridden by child controllers that need authentication
  7. def authenticate_api_key
  8. # Default implementation - do nothing
  9. end
  10. private
  11. def set_content_type
  12. response.headers['Content-Type'] = 'application/json'
  13. end
  14. def render_success(data, meta = {}, status = :ok)
  15. response_data = {
  16. success: true,
  17. data: data
  18. }
  19. response_data[:meta] = meta if meta.present?
  20. render json: response_data, status: status
  21. end
  22. def current_api_user
  23. @api_user
  24. end
  25. def paginate(collection, per_page = 20)
  26. page = params[:page]&.to_i || 1
  27. per_page = params[:per_page]&.to_i || per_page
  28. per_page = [per_page, 100].min # Cap at 100 items per page
  29. collection.page(page).per(per_page)
  30. end
  31. end

app/controllers/api/v1/categories_controller.rb

0.0% lines covered

100.0% branches covered

77 relevant lines. 0 lines covered and 77 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class CategoriesController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:index, :show]
  5. before_action :set_category, only: [:show, :update, :destroy]
  6. # GET /api/v1/categories
  7. def index
  8. categories = Term.for_taxonomy('category').includes(:term_relationships, :children)
  9. # Filter by parent
  10. categories = categories.where(parent_id: params[:parent_id]) if params[:parent_id].present?
  11. categories = categories.root_terms if params[:root_only] == 'true'
  12. # Search
  13. categories = categories.where('name LIKE ?', "%#{params[:q]}%") if params[:q].present?
  14. @categories = paginate(categories.ordered)
  15. render_success(
  16. @categories.map { |cat| category_serializer(cat) }
  17. )
  18. end
  19. # GET /api/v1/categories/:id
  20. def show
  21. render_success(category_serializer(@category, detailed: true))
  22. end
  23. # POST /api/v1/categories
  24. def create
  25. unless current_api_user.can_edit_others_posts?
  26. return render_error('You do not have permission to create categories', :forbidden)
  27. end
  28. @category = Term.new(category_params.merge(taxonomy: Taxonomy.categories))
  29. if @category.save
  30. render_success(category_serializer(@category), {}, :created)
  31. else
  32. render_error(@category.errors.full_messages.join(', '))
  33. end
  34. end
  35. # PATCH/PUT /api/v1/categories/:id
  36. def update
  37. unless current_api_user.can_edit_others_posts?
  38. return render_error('You do not have permission to edit categories', :forbidden)
  39. end
  40. if @category.update(category_params)
  41. render_success(category_serializer(@category))
  42. else
  43. render_error(@category.errors.full_messages.join(', '))
  44. end
  45. end
  46. # DELETE /api/v1/categories/:id
  47. def destroy
  48. unless current_api_user.administrator?
  49. return render_error('Only administrators can delete categories', :forbidden)
  50. end
  51. @category.destroy
  52. render_success({ message: 'Category deleted successfully' })
  53. end
  54. private
  55. def set_category
  56. @category = Term.for_taxonomy('category').friendly.find(params[:id])
  57. end
  58. def category_params
  59. params.require(:category).permit(:name, :slug, :description, :parent_id)
  60. end
  61. def category_serializer(category, detailed: false)
  62. data = {
  63. id: category.id,
  64. name: category.name,
  65. slug: category.slug,
  66. description: category.description,
  67. post_count: category.post_count,
  68. parent: category.parent ? { id: category.parent.id, name: category.parent.name, slug: category.parent.slug } : nil,
  69. children_count: category.children.count,
  70. url: category_url(category.slug)
  71. }
  72. if detailed
  73. data.merge!(
  74. children: category.children.map { |c| { id: c.id, name: c.name, slug: c.slug } },
  75. recent_posts: category.posts.published.recent.limit(5).map { |p|
  76. { id: p.id, title: p.title, slug: p.slug, published_at: p.published_at }
  77. }
  78. )
  79. end
  80. data
  81. end
  82. end
  83. end
  84. end

app/controllers/api/v1/channels_controller.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class ChannelsController < BaseController
  4. before_action :set_channel, only: [:show, :update, :destroy]
  5. # GET /api/v1/channels
  6. def index
  7. channels = Channel.all.order(:name)
  8. render_success(
  9. channels.map { |channel| channel_serializer(channel) },
  10. { total: channels.count }
  11. )
  12. end
  13. # GET /api/v1/channels/:id
  14. def show
  15. render_success(channel_serializer(@channel, detailed: true))
  16. end
  17. # POST /api/v1/channels
  18. def create
  19. unless current_api_user&.can_manage_channels?
  20. return render_error('You do not have permission to create channels', :forbidden)
  21. end
  22. @channel = Channel.new(channel_params)
  23. if @channel.save
  24. render_success(channel_serializer(@channel), {}, :created)
  25. else
  26. render_error(@channel.errors.full_messages.join(', '))
  27. end
  28. end
  29. # PATCH/PUT /api/v1/channels/:id
  30. def update
  31. unless current_api_user&.can_manage_channels?
  32. return render_error('You do not have permission to update channels', :forbidden)
  33. end
  34. if @channel.update(channel_params)
  35. render_success(channel_serializer(@channel))
  36. else
  37. render_error(@channel.errors.full_messages.join(', '))
  38. end
  39. end
  40. # DELETE /api/v1/channels/:id
  41. def destroy
  42. unless current_api_user&.can_manage_channels?
  43. return render_error('You do not have permission to delete channels', :forbidden)
  44. end
  45. @channel.destroy
  46. render_success({ message: 'Channel deleted successfully' })
  47. end
  48. private
  49. def set_channel
  50. @channel = Channel.find(params[:id])
  51. end
  52. def channel_params
  53. params.require(:channel).permit(:name, :slug, :domain, :locale, :enabled, metadata: {}, settings: {})
  54. end
  55. def channel_serializer(channel, detailed: false)
  56. data = {
  57. id: channel.id,
  58. name: channel.name,
  59. slug: channel.slug,
  60. domain: channel.domain,
  61. locale: channel.locale,
  62. enabled: channel.enabled,
  63. metadata: channel.metadata,
  64. settings: channel.settings,
  65. created_at: channel.created_at,
  66. updated_at: channel.updated_at,
  67. content_stats: {
  68. posts_count: channel.posts.count,
  69. pages_count: channel.pages.count,
  70. media_count: channel.media.count
  71. },
  72. override_stats: {
  73. total_overrides: channel.channel_overrides.count,
  74. active_overrides: channel.channel_overrides.enabled.count,
  75. exclusions: channel.channel_overrides.exclusions.count,
  76. data_overrides: channel.channel_overrides.overrides.count
  77. }
  78. }
  79. if detailed
  80. data.merge!(
  81. overrides: channel.channel_overrides.includes(:resource).map do |override|
  82. {
  83. id: override.id,
  84. resource_type: override.resource_type,
  85. resource_id: override.resource_id,
  86. resource_name: override.resource_name,
  87. kind: override.kind,
  88. path: override.path,
  89. data: override.data,
  90. enabled: override.enabled,
  91. created_at: override.created_at,
  92. updated_at: override.updated_at
  93. }
  94. end
  95. )
  96. end
  97. data
  98. end
  99. end
  100. end
  101. end

app/controllers/api/v1/comments_controller.rb

0.0% lines covered

100.0% branches covered

165 relevant lines. 0 lines covered and 165 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class CommentsController < BaseController
  4. # No authentication required for public comment creation
  5. before_action :set_comment, only: [:show, :update, :destroy, :approve, :spam]
  6. # GET /api/v1/comments
  7. def index
  8. comments = Comment.kept.includes(:user, :commentable)
  9. # Filter by status
  10. comments = comments.where(status: params[:status]) if params[:status].present?
  11. # Filter by commentable
  12. if params[:post_id].present?
  13. comments = comments.where(commentable_type: 'Post', commentable_id: params[:post_id])
  14. elsif params[:page_id].present?
  15. comments = comments.where(commentable_type: 'Page', commentable_id: params[:page_id])
  16. end
  17. # Only approved for non-authenticated users
  18. unless @api_user&.can_edit_others_posts?
  19. comments = comments.approved
  20. end
  21. # Root comments only or include replies
  22. comments = comments.root_comments if params[:root_only] == 'true'
  23. @comments = paginate(comments.recent)
  24. render_success(
  25. @comments.map { |comment| comment_serializer(comment) }
  26. )
  27. end
  28. # GET /api/v1/comments/:id
  29. def show
  30. render_success(comment_serializer(@comment, detailed: true))
  31. end
  32. # POST /api/v1/comments
  33. def create
  34. # Check if comments are enabled
  35. unless SiteSetting.get('comments_enabled', true)
  36. return render json: { error: 'Comments are disabled for this site' }, status: :forbidden
  37. end
  38. # Check if registration is required and user is not logged in
  39. if SiteSetting.get('comment_registration_required', false) && !@api_user
  40. return render json: { error: 'You must be logged in to post comments' }, status: :unauthorized
  41. end
  42. @comment = Comment.new(comment_params)
  43. @comment.user = @api_user if @api_user
  44. # Check Akismet if enabled
  45. if akismet_enabled?
  46. akismet_data = {
  47. user_ip: request.remote_ip,
  48. user_agent: request.user_agent,
  49. referrer: request.referer,
  50. permalink: commentable_url(@comment.commentable),
  51. comment_type: 'comment',
  52. comment_author: @comment.author_name || @comment.user&.email,
  53. comment_author_email: @comment.author_email || @comment.user&.email,
  54. comment_author_url: @comment.author_url,
  55. comment_content: @comment.content
  56. }
  57. akismet = AkismetService.new(akismet_api_key, site_url)
  58. if akismet.spam?(akismet_data)
  59. @comment.status = :spam
  60. else
  61. @comment.status = SiteSetting.get('comments_moderation', true) ? :pending : :approved
  62. end
  63. else
  64. @comment.status = SiteSetting.get('comments_moderation', true) ? :pending : :approved
  65. end
  66. if @comment.save
  67. render json: { success: true, comment: { id: @comment.id, status: @comment.status } }, status: :created
  68. else
  69. render json: { error: @comment.errors.full_messages.join(', ') }, status: :unprocessable_entity
  70. end
  71. end
  72. # PATCH/PUT /api/v1/comments/:id
  73. def update
  74. unless can_edit_comment?
  75. return render_error('You do not have permission to edit this comment', :forbidden)
  76. end
  77. if @comment.update(comment_update_params)
  78. render_success(comment_serializer(@comment))
  79. else
  80. render_error(@comment.errors.full_messages.join(', '))
  81. end
  82. end
  83. # DELETE /api/v1/comments/:id
  84. def destroy
  85. unless can_delete_comment?
  86. return render_error('You do not have permission to delete this comment', :forbidden)
  87. end
  88. @comment.destroy
  89. render_success({ message: 'Comment deleted successfully' })
  90. end
  91. # PATCH /api/v1/comments/:id/approve
  92. def approve
  93. unless current_api_user.can_edit_others_posts?
  94. return render_error('You do not have permission to approve comments', :forbidden)
  95. end
  96. @comment.update(status: :approved)
  97. render_success(comment_serializer(@comment))
  98. end
  99. # PATCH /api/v1/comments/:id/spam
  100. def spam
  101. unless current_api_user.can_edit_others_posts?
  102. return render_error('You do not have permission to mark comments as spam', :forbidden)
  103. end
  104. @comment.update(status: :spam)
  105. render_success(comment_serializer(@comment))
  106. end
  107. private
  108. def set_comment
  109. @comment = Comment.find(params[:id])
  110. end
  111. def can_edit_comment?
  112. return true if @api_user&.can_edit_others_posts?
  113. @comment.user_id == @api_user&.id
  114. end
  115. def can_delete_comment?
  116. return true if @api_user&.administrator?
  117. @comment.user_id == @api_user&.id
  118. end
  119. def comment_params
  120. params.require(:comment).permit(
  121. :content, :author_name, :author_email, :author_url, :author_ip, :author_agent,
  122. :comment_type, :comment_approved, :comment_parent_id,
  123. :commentable_type, :commentable_id, :parent_id
  124. )
  125. end
  126. def comment_update_params
  127. if current_api_user&.can_edit_others_posts?
  128. params.require(:comment).permit(:content, :status, :author_name, :author_email, :author_url)
  129. else
  130. params.require(:comment).permit(:content)
  131. end
  132. end
  133. def comment_serializer(comment, detailed: false)
  134. data = {
  135. id: comment.id,
  136. content: comment.content,
  137. author: comment.author,
  138. author_email: comment.author_email,
  139. author_url: comment.author_url,
  140. status: comment.status,
  141. created_at: comment.created_at,
  142. updated_at: comment.updated_at,
  143. commentable: {
  144. type: comment.commentable_type,
  145. id: comment.commentable_id,
  146. title: comment.commentable.try(:title)
  147. },
  148. parent_id: comment.parent_id,
  149. replies_count: comment.replies.count
  150. }
  151. if detailed
  152. data.merge!(
  153. replies: comment.replies.approved.map { |r| comment_serializer(r) },
  154. user: comment.user ? { id: comment.user.id, email: comment.user.email } : nil
  155. )
  156. end
  157. data
  158. end
  159. def akismet_enabled?
  160. SiteSetting.get('akismet_enabled', false) && SiteSetting.get('akismet_api_key', '').present?
  161. end
  162. def akismet_api_key
  163. SiteSetting.get('akismet_api_key', '')
  164. end
  165. def site_url
  166. SiteSetting.get('site_url', 'http://localhost:3000')
  167. end
  168. def commentable_url(commentable)
  169. case commentable
  170. when Post
  171. "#{site_url}/posts/#{commentable.slug}"
  172. when Page
  173. "#{site_url}/pages/#{commentable.slug}"
  174. else
  175. site_url
  176. end
  177. end
  178. end
  179. end
  180. end

app/controllers/api/v1/consent_controller.rb

0.0% lines covered

100.0% branches covered

227 relevant lines. 0 lines covered and 227 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::ConsentController < Api::V1::BaseController
  2. before_action :authenticate_user!, only: [:create, :update, :withdraw]
  3. before_action :set_consent_configuration, only: [:configuration, :region]
  4. # GET /api/v1/consent/configuration
  5. def configuration
  6. render json: {
  7. consent_categories_with_defaults: @consent_config.consent_categories_with_defaults,
  8. banner_settings_with_defaults: @consent_config.banner_settings_with_defaults,
  9. geolocation_settings_with_defaults: @consent_config.geolocation_settings_with_defaults,
  10. pixel_consent_mapping_with_defaults: @consent_config.pixel_consent_mapping_with_defaults,
  11. version: @consent_config.version || '1.0'
  12. }
  13. end
  14. # GET /api/v1/consent/region
  15. def region
  16. user_ip = request.remote_ip || request.env['HTTP_X_FORWARDED_FOR']&.split(',')&.first
  17. begin
  18. detected_region = @consent_config.get_region_from_ip(user_ip)
  19. render json: {
  20. region: detected_region,
  21. ip: user_ip,
  22. timestamp: Time.current.iso8601
  23. }
  24. rescue => e
  25. Rails.logger.error "Region detection error: #{e.message}"
  26. render json: {
  27. region: 'unknown',
  28. ip: user_ip,
  29. timestamp: Time.current.iso8601,
  30. error: 'Region detection failed'
  31. }
  32. end
  33. end
  34. # POST /api/v1/consent
  35. def create
  36. consent_params = params.require(:consent).permit!
  37. region = params[:region]
  38. timestamp = params[:timestamp]
  39. begin
  40. # Save consent for each category
  41. saved_consents = []
  42. consent_params.each do |category, consent_data|
  43. next unless consent_data[:granted]
  44. # Find or create user consent
  45. user_consent = current_user.user_consents.find_or_initialize_by(
  46. consent_type: category
  47. )
  48. user_consent.assign_attributes(
  49. consent_text: consent_data[:consent_text],
  50. ip_address: consent_data[:ip_address],
  51. user_agent: consent_data[:user_agent],
  52. granted: true,
  53. granted_at: Time.parse(consent_data[:granted_at]),
  54. withdrawn_at: nil,
  55. details: {
  56. region: region,
  57. timestamp: timestamp,
  58. consent_version: @consent_config.version || '1.0'
  59. }
  60. )
  61. user_consent.save!
  62. saved_consents << user_consent
  63. end
  64. # Log consent event
  65. log_consent_event('consent_granted', {
  66. user_id: current_user.id,
  67. consents: saved_consents.map(&:consent_type),
  68. region: region,
  69. timestamp: timestamp
  70. })
  71. render json: {
  72. success: true,
  73. message: 'Consent saved successfully',
  74. consents: saved_consents.map do |consent|
  75. {
  76. type: consent.consent_type,
  77. granted: consent.granted,
  78. granted_at: consent.granted_at
  79. }
  80. end
  81. }
  82. rescue => e
  83. Rails.logger.error "Consent save error: #{e.message}"
  84. render json: {
  85. success: false,
  86. error: 'Failed to save consent'
  87. }, status: :unprocessable_entity
  88. end
  89. end
  90. # PATCH /api/v1/consent
  91. def update
  92. consent_params = params.require(:consent).permit!
  93. region = params[:region]
  94. timestamp = params[:timestamp]
  95. begin
  96. # Update existing consents
  97. updated_consents = []
  98. consent_params.each do |category, consent_data|
  99. user_consent = current_user.user_consents.find_by(consent_type: category)
  100. next unless user_consent
  101. if consent_data[:granted]
  102. user_consent.update!(
  103. granted: true,
  104. granted_at: Time.parse(consent_data[:granted_at]),
  105. withdrawn_at: nil,
  106. details: user_consent.details.merge({
  107. region: region,
  108. timestamp: timestamp,
  109. consent_version: @consent_config.version || '1.0'
  110. })
  111. )
  112. else
  113. user_consent.withdraw!
  114. end
  115. updated_consents << user_consent
  116. end
  117. # Log consent update event
  118. log_consent_event('consent_updated', {
  119. user_id: current_user.id,
  120. consents: updated_consents.map(&:consent_type),
  121. region: region,
  122. timestamp: timestamp
  123. })
  124. render json: {
  125. success: true,
  126. message: 'Consent updated successfully',
  127. consents: updated_consents.map do |consent|
  128. {
  129. type: consent.consent_type,
  130. granted: consent.granted,
  131. granted_at: consent.granted_at,
  132. withdrawn_at: consent.withdrawn_at
  133. }
  134. end
  135. }
  136. rescue => e
  137. Rails.logger.error "Consent update error: #{e.message}"
  138. render json: {
  139. success: false,
  140. error: 'Failed to update consent'
  141. }, status: :unprocessable_entity
  142. end
  143. end
  144. # DELETE /api/v1/consent/:consent_type
  145. def withdraw
  146. consent_type = params[:consent_type]
  147. begin
  148. user_consent = current_user.user_consents.find_by(consent_type: consent_type)
  149. if user_consent
  150. user_consent.withdraw!
  151. # Log consent withdrawal event
  152. log_consent_event('consent_withdrawn', {
  153. user_id: current_user.id,
  154. consent_type: consent_type,
  155. timestamp: Time.current.iso8601
  156. })
  157. render json: {
  158. success: true,
  159. message: 'Consent withdrawn successfully'
  160. }
  161. else
  162. render json: {
  163. success: false,
  164. error: 'Consent not found'
  165. }, status: :not_found
  166. end
  167. rescue => e
  168. Rails.logger.error "Consent withdrawal error: #{e.message}"
  169. render json: {
  170. success: false,
  171. error: 'Failed to withdraw consent'
  172. }, status: :unprocessable_entity
  173. end
  174. end
  175. # GET /api/v1/consent/status
  176. def status
  177. if user_signed_in?
  178. consents = current_user.user_consents.includes(:user)
  179. render json: {
  180. user_id: current_user.id,
  181. consents: consents.map do |consent|
  182. {
  183. type: consent.consent_type,
  184. granted: consent.granted?,
  185. granted_at: consent.granted_at,
  186. withdrawn_at: consent.withdrawn_at,
  187. details: consent.details
  188. }
  189. end,
  190. timestamp: Time.current.iso8601
  191. }
  192. else
  193. render json: {
  194. user_id: nil,
  195. consents: [],
  196. timestamp: Time.current.iso8601
  197. }
  198. end
  199. end
  200. # GET /api/v1/consent/pixels
  201. def pixels
  202. # Get all active pixels with consent requirements
  203. pixels = Pixel.active.includes(:tenant)
  204. pixel_data = pixels.map do |pixel|
  205. required_consent = @consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
  206. {
  207. id: pixel.id,
  208. name: pixel.name,
  209. pixel_type: pixel.pixel_type,
  210. pixel_id: pixel.pixel_id,
  211. position: pixel.position,
  212. required_consent: required_consent,
  213. consent_required: @consent_config.is_pixel_consent_required?(pixel.pixel_type)
  214. }
  215. end
  216. render json: {
  217. pixels: pixel_data,
  218. timestamp: Time.current.iso8601
  219. }
  220. end
  221. private
  222. def set_consent_configuration
  223. @consent_config = ConsentConfiguration.active.first
  224. unless @consent_config
  225. render json: {
  226. error: 'No active consent configuration found'
  227. }, status: :not_found
  228. end
  229. end
  230. def log_consent_event(event_type, data)
  231. # Log to analytics if available
  232. if defined?(AnalyticsEvent)
  233. AnalyticsEvent.create!(
  234. event_type: event_type,
  235. user_id: data[:user_id],
  236. properties: data,
  237. timestamp: Time.current
  238. )
  239. end
  240. # Log to Rails logger
  241. Rails.logger.info "Consent Event: #{event_type} - #{data}"
  242. end
  243. end

app/controllers/api/v1/content_types_controller.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::ContentTypesController < Api::V1::BaseController
  2. before_action :set_content_type, only: [:show]
  3. # GET /api/v1/content_types
  4. def index
  5. @content_types = ContentType.active.ordered
  6. render json: {
  7. data: @content_types.map { |ct| content_type_json(ct) },
  8. meta: {
  9. total: @content_types.count
  10. }
  11. }
  12. end
  13. # GET /api/v1/content_types/:ident
  14. def show
  15. render json: {
  16. data: content_type_json(@content_type)
  17. }
  18. end
  19. private
  20. def set_content_type
  21. @content_type = ContentType.find_by_ident(params[:id]) || ContentType.find(params[:id])
  22. unless @content_type
  23. render json: { error: 'Content type not found' }, status: :not_found
  24. end
  25. end
  26. def content_type_json(content_type)
  27. {
  28. id: content_type.id,
  29. ident: content_type.ident,
  30. label: content_type.label,
  31. singular: content_type.singular,
  32. plural: content_type.plural,
  33. description: content_type.description,
  34. icon: content_type.icon,
  35. public: content_type.public,
  36. hierarchical: content_type.hierarchical,
  37. has_archive: content_type.has_archive,
  38. menu_position: content_type.menu_position,
  39. supports: content_type.supports,
  40. capabilities: content_type.capabilities,
  41. rest_base: content_type.rest_endpoint,
  42. active: content_type.active,
  43. posts_count: content_type.posts.count,
  44. created_at: content_type.created_at.iso8601,
  45. updated_at: content_type.updated_at.iso8601,
  46. _links: {
  47. self: api_v1_content_type_url(content_type.ident),
  48. posts: api_v1_posts_url(content_type: content_type.ident)
  49. }
  50. }
  51. end
  52. end

app/controllers/api/v1/docs_controller.rb

0.0% lines covered

100.0% branches covered

132 relevant lines. 0 lines covered and 132 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class DocsController < ApplicationController
  4. skip_before_action :verify_authenticity_token
  5. def index
  6. @api_endpoints = build_endpoint_tree
  7. render layout: 'api_docs'
  8. end
  9. private
  10. def build_endpoint_tree
  11. {
  12. authentication: {
  13. name: 'Authentication',
  14. endpoints: [
  15. { method: 'POST', path: '/api/v1/auth/login', description: 'Login and get API token' },
  16. { method: 'POST', path: '/api/v1/auth/register', description: 'Register new user' },
  17. { method: 'POST', path: '/api/v1/auth/validate', description: 'Validate API token' }
  18. ]
  19. },
  20. posts: {
  21. name: 'Posts',
  22. endpoints: [
  23. { method: 'GET', path: '/api/v1/posts', description: 'List all posts' },
  24. { method: 'GET', path: '/api/v1/posts/:id', description: 'Get single post' },
  25. { method: 'POST', path: '/api/v1/posts', description: 'Create new post', auth: true },
  26. { method: 'PATCH', path: '/api/v1/posts/:id', description: 'Update post', auth: true },
  27. { method: 'DELETE', path: '/api/v1/posts/:id', description: 'Delete post', auth: true }
  28. ]
  29. },
  30. pages: {
  31. name: 'Pages',
  32. endpoints: [
  33. { method: 'GET', path: '/api/v1/pages', description: 'List all pages' },
  34. { method: 'GET', path: '/api/v1/pages/:id', description: 'Get single page' },
  35. { method: 'POST', path: '/api/v1/pages', description: 'Create new page', auth: true },
  36. { method: 'PATCH', path: '/api/v1/pages/:id', description: 'Update page', auth: true },
  37. { method: 'DELETE', path: '/api/v1/pages/:id', description: 'Delete page', auth: true }
  38. ]
  39. },
  40. categories: {
  41. name: 'Categories',
  42. endpoints: [
  43. { method: 'GET', path: '/api/v1/categories', description: 'List all categories' },
  44. { method: 'GET', path: '/api/v1/categories/:id', description: 'Get single category' },
  45. { method: 'POST', path: '/api/v1/categories', description: 'Create category', auth: true },
  46. { method: 'PATCH', path: '/api/v1/categories/:id', description: 'Update category', auth: true },
  47. { method: 'DELETE', path: '/api/v1/categories/:id', description: 'Delete category', auth: true }
  48. ]
  49. },
  50. tags: {
  51. name: 'Tags',
  52. endpoints: [
  53. { method: 'GET', path: '/api/v1/tags', description: 'List all tags' },
  54. { method: 'GET', path: '/api/v1/tags/:id', description: 'Get single tag' },
  55. { method: 'POST', path: '/api/v1/tags', description: 'Create tag', auth: true },
  56. { method: 'PATCH', path: '/api/v1/tags/:id', description: 'Update tag', auth: true },
  57. { method: 'DELETE', path: '/api/v1/tags/:id', description: 'Delete tag', auth: true }
  58. ]
  59. },
  60. comments: {
  61. name: 'Comments',
  62. endpoints: [
  63. { method: 'GET', path: '/api/v1/comments', description: 'List all comments' },
  64. { method: 'GET', path: '/api/v1/comments/:id', description: 'Get single comment' },
  65. { method: 'POST', path: '/api/v1/comments', description: 'Create comment' },
  66. { method: 'PATCH', path: '/api/v1/comments/:id/approve', description: 'Approve comment', auth: true },
  67. { method: 'PATCH', path: '/api/v1/comments/:id/spam', description: 'Mark as spam', auth: true },
  68. { method: 'DELETE', path: '/api/v1/comments/:id', description: 'Delete comment', auth: true }
  69. ]
  70. },
  71. media: {
  72. name: 'Media',
  73. endpoints: [
  74. { method: 'GET', path: '/api/v1/media', description: 'List all media' },
  75. { method: 'GET', path: '/api/v1/media/:id', description: 'Get single media' },
  76. { method: 'POST', path: '/api/v1/media', description: 'Upload media', auth: true },
  77. { method: 'PATCH', path: '/api/v1/media/:id', description: 'Update media', auth: true },
  78. { method: 'DELETE', path: '/api/v1/media/:id', description: 'Delete media', auth: true }
  79. ]
  80. },
  81. users: {
  82. name: 'Users',
  83. endpoints: [
  84. { method: 'GET', path: '/api/v1/users', description: 'List all users', auth: true, admin: true },
  85. { method: 'GET', path: '/api/v1/users/me', description: 'Get current user', auth: true },
  86. { method: 'PATCH', path: '/api/v1/users/update_profile', description: 'Update profile', auth: true },
  87. { method: 'POST', path: '/api/v1/users/regenerate_token', description: 'Regenerate API token', auth: true }
  88. ]
  89. },
  90. menus: {
  91. name: 'Menus',
  92. endpoints: [
  93. { method: 'GET', path: '/api/v1/menus', description: 'List all menus' },
  94. { method: 'GET', path: '/api/v1/menus/:id', description: 'Get menu with items' }
  95. ]
  96. },
  97. settings: {
  98. name: 'Settings',
  99. endpoints: [
  100. { method: 'GET', path: '/api/v1/settings', description: 'List all settings', auth: true },
  101. { method: 'GET', path: '/api/v1/settings/get/:key', description: 'Get setting value', auth: true },
  102. { method: 'POST', path: '/api/v1/settings', description: 'Create setting', auth: true, admin: true }
  103. ]
  104. },
  105. system: {
  106. name: 'System',
  107. endpoints: [
  108. { method: 'GET', path: '/api/v1/system/info', description: 'Get API information' },
  109. { method: 'GET', path: '/api/v1/system/stats', description: 'Get system statistics', auth: true, admin: true }
  110. ]
  111. },
  112. gdpr: {
  113. name: 'GDPR Compliance',
  114. description: 'GDPR compliance endpoints for data export, erasure, and consent management',
  115. endpoints: [
  116. { method: 'GET', path: '/api/v1/gdpr/data-export/:user_id', description: 'Request personal data export (Article 20)', auth: true },
  117. { method: 'GET', path: '/api/v1/gdpr/data-export/download/:token', description: 'Download exported personal data', auth: true },
  118. { method: 'POST', path: '/api/v1/gdpr/data-erasure/:user_id', description: 'Request personal data erasure (Article 17)', auth: true },
  119. { method: 'POST', path: '/api/v1/gdpr/data-erasure/confirm/:token', description: 'Confirm data erasure request', auth: true },
  120. { method: 'GET', path: '/api/v1/gdpr/data-portability/:user_id', description: 'Get data portability information (Article 20)', auth: true },
  121. { method: 'GET', path: '/api/v1/gdpr/requests', description: 'List GDPR requests for user', auth: true },
  122. { method: 'GET', path: '/api/v1/gdpr/status/:user_id', description: 'Get GDPR compliance status', auth: true },
  123. { method: 'POST', path: '/api/v1/gdpr/consent/:user_id', description: 'Record user consent (Article 7)', auth: true },
  124. { method: 'DELETE', path: '/api/v1/gdpr/consent/:user_id', description: 'Withdraw user consent', auth: true },
  125. { method: 'GET', path: '/api/v1/gdpr/audit-log', description: 'Get GDPR audit log (admin only)', auth: true, admin: true }
  126. ]
  127. }
  128. }
  129. end
  130. end
  131. end
  132. end

app/controllers/api/v1/gdpr_controller.rb

0.0% lines covered

100.0% branches covered

273 relevant lines. 0 lines covered and 273 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class GdprController < BaseController
  4. before_action :authenticate_user!
  5. before_action :set_user, only: [:export_data, :request_erasure, :data_portability]
  6. before_action :validate_gdpr_request, only: [:export_data, :request_erasure]
  7. # GET /api/v1/gdpr/data-export/:user_id
  8. # Export personal data for a user (GDPR Article 20 - Right to Data Portability)
  9. def export_data
  10. begin
  11. export_request = GdprService.create_export_request(@user, current_user)
  12. render json: {
  13. success: true,
  14. message: 'Personal data export request created successfully',
  15. data: {
  16. request_id: export_request.id,
  17. token: export_request.token,
  18. status: export_request.status,
  19. requested_at: export_request.created_at,
  20. estimated_completion: 5.minutes.from_now,
  21. download_url: api_v1_gdpr_download_export_url(export_request.token)
  22. }
  23. }, status: :created
  24. rescue => e
  25. render json: {
  26. success: false,
  27. message: 'Failed to create export request',
  28. error: e.message
  29. }, status: :unprocessable_entity
  30. end
  31. end
  32. # GET /api/v1/gdpr/data-export/download/:token
  33. # Download exported personal data
  34. def download_export
  35. export_request = PersonalDataExportRequest.find_by(token: params[:token])
  36. unless export_request
  37. render json: {
  38. success: false,
  39. message: 'Export request not found'
  40. }, status: :not_found
  41. return
  42. end
  43. unless export_request.completed?
  44. render json: {
  45. success: false,
  46. message: 'Export is not ready yet',
  47. data: {
  48. status: export_request.status,
  49. estimated_completion: export_request.created_at + 5.minutes
  50. }
  51. }, status: :accepted
  52. return
  53. end
  54. unless File.exist?(export_request.file_path)
  55. render json: {
  56. success: false,
  57. message: 'Export file not found'
  58. }, status: :not_found
  59. return
  60. end
  61. send_file export_request.file_path,
  62. filename: "personal_data_#{export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
  63. type: 'application/json',
  64. disposition: 'attachment'
  65. rescue => e
  66. render json: {
  67. success: false,
  68. message: 'Download failed',
  69. error: e.message
  70. }, status: :internal_server_error
  71. end
  72. # POST /api/v1/gdpr/data-erasure/:user_id
  73. # Request data erasure (GDPR Article 17 - Right to Erasure)
  74. def request_erasure
  75. begin
  76. erasure_request = GdprService.create_erasure_request(@user, current_user, params[:reason])
  77. render json: {
  78. success: true,
  79. message: 'Data erasure request created successfully',
  80. data: {
  81. request_id: erasure_request.id,
  82. token: erasure_request.token,
  83. status: erasure_request.status,
  84. requested_at: erasure_request.created_at,
  85. reason: erasure_request.reason,
  86. confirmation_url: api_v1_gdpr_confirm_erasure_url(erasure_request.token),
  87. metadata: erasure_request.metadata
  88. }
  89. }, status: :created
  90. rescue => e
  91. render json: {
  92. success: false,
  93. message: 'Failed to create erasure request',
  94. error: e.message
  95. }, status: :unprocessable_entity
  96. end
  97. end
  98. # POST /api/v1/gdpr/data-erasure/confirm/:token
  99. # Confirm data erasure request
  100. def confirm_erasure
  101. erasure_request = PersonalDataErasureRequest.find_by(token: params[:token])
  102. unless erasure_request
  103. render json: {
  104. success: false,
  105. message: 'Erasure request not found'
  106. }, status: :not_found
  107. return
  108. end
  109. if erasure_request.status != 'pending_confirmation'
  110. render json: {
  111. success: false,
  112. message: 'This request has already been processed',
  113. data: { status: erasure_request.status }
  114. }, status: :unprocessable_entity
  115. return
  116. end
  117. begin
  118. GdprService.confirm_erasure_request(erasure_request, current_user)
  119. render json: {
  120. success: true,
  121. message: 'Data erasure confirmed and queued for processing',
  122. data: {
  123. request_id: erasure_request.id,
  124. status: erasure_request.status,
  125. confirmed_at: erasure_request.confirmed_at,
  126. estimated_completion: 10.minutes.from_now
  127. }
  128. }, status: :ok
  129. rescue => e
  130. render json: {
  131. success: false,
  132. message: 'Failed to confirm erasure request',
  133. error: e.message
  134. }, status: :unprocessable_entity
  135. end
  136. end
  137. # GET /api/v1/gdpr/data-portability/:user_id
  138. # Get data portability information (GDPR Article 20)
  139. def data_portability
  140. begin
  141. portability_data = GdprService.generate_portability_data(@user)
  142. render json: {
  143. success: true,
  144. message: 'Data portability information retrieved successfully',
  145. data: portability_data
  146. }, status: :ok
  147. rescue => e
  148. render json: {
  149. success: false,
  150. message: 'Failed to retrieve portability data',
  151. error: e.message
  152. }, status: :internal_server_error
  153. end
  154. end
  155. # GET /api/v1/gdpr/requests
  156. # List all GDPR requests for the current user or admin
  157. def requests
  158. if current_user.administrator?
  159. export_requests = PersonalDataExportRequest.includes(:user).recent.limit(50)
  160. erasure_requests = PersonalDataErasureRequest.includes(:user).recent.limit(50)
  161. else
  162. export_requests = PersonalDataExportRequest.where(user: current_user).recent.limit(50)
  163. erasure_requests = PersonalDataErasureRequest.where(user: current_user).recent.limit(50)
  164. end
  165. render json: {
  166. success: true,
  167. data: {
  168. export_requests: export_requests.map do |req|
  169. {
  170. id: req.id,
  171. user_email: req.email,
  172. status: req.status,
  173. requested_at: req.created_at,
  174. completed_at: req.completed_at,
  175. download_url: req.completed? ? api_v1_gdpr_download_export_url(req.token) : nil
  176. }
  177. end,
  178. erasure_requests: erasure_requests.map do |req|
  179. {
  180. id: req.id,
  181. user_email: req.email,
  182. status: req.status,
  183. reason: req.reason,
  184. requested_at: req.created_at,
  185. confirmed_at: req.confirmed_at,
  186. completed_at: req.completed_at
  187. }
  188. end
  189. }
  190. }, status: :ok
  191. end
  192. # GET /api/v1/gdpr/status/:user_id
  193. # Get GDPR compliance status for a user
  194. def status
  195. begin
  196. status_data = GdprService.get_user_gdpr_status(@user)
  197. render json: {
  198. success: true,
  199. data: status_data
  200. }, status: :ok
  201. rescue => e
  202. render json: {
  203. success: false,
  204. message: 'Failed to retrieve GDPR status',
  205. error: e.message
  206. }, status: :internal_server_error
  207. end
  208. end
  209. # POST /api/v1/gdpr/consent/:user_id
  210. # Record user consent (GDPR Article 7)
  211. def record_consent
  212. begin
  213. consent_data = GdprService.record_user_consent(@user, params[:consent_type], params[:consent_data])
  214. render json: {
  215. success: true,
  216. message: 'Consent recorded successfully',
  217. data: consent_data
  218. }, status: :created
  219. rescue => e
  220. render json: {
  221. success: false,
  222. message: 'Failed to record consent',
  223. error: e.message
  224. }, status: :unprocessable_entity
  225. end
  226. end
  227. # DELETE /api/v1/gdpr/consent/:user_id
  228. # Withdraw user consent
  229. def withdraw_consent
  230. begin
  231. GdprService.withdraw_user_consent(@user, params[:consent_type])
  232. render json: {
  233. success: true,
  234. message: 'Consent withdrawn successfully'
  235. }, status: :ok
  236. rescue => e
  237. render json: {
  238. success: false,
  239. message: 'Failed to withdraw consent',
  240. error: e.message
  241. }, status: :unprocessable_entity
  242. end
  243. end
  244. # GET /api/v1/gdpr/audit-log
  245. # Get GDPR audit log (admin only)
  246. def audit_log
  247. unless current_user.administrator?
  248. render json: {
  249. success: false,
  250. message: 'Access denied'
  251. }, status: :forbidden
  252. return
  253. end
  254. begin
  255. audit_data = GdprService.get_audit_log(params[:page] || 1, params[:per_page] || 50)
  256. render json: {
  257. success: true,
  258. data: audit_data
  259. }, status: :ok
  260. rescue => e
  261. render json: {
  262. success: false,
  263. message: 'Failed to retrieve audit log',
  264. error: e.message
  265. }, status: :internal_server_error
  266. end
  267. end
  268. private
  269. def set_user
  270. @user = User.find(params[:user_id])
  271. # Users can only access their own data unless they're admin
  272. unless current_user.administrator? || @user == current_user
  273. render json: {
  274. success: false,
  275. message: 'Access denied'
  276. }, status: :forbidden
  277. end
  278. rescue ActiveRecord::RecordNotFound
  279. render json: {
  280. success: false,
  281. message: 'User not found'
  282. }, status: :not_found
  283. end
  284. def validate_gdpr_request
  285. # Prevent admins from being erased
  286. if params[:action] == 'request_erasure' && @user.administrator?
  287. render json: {
  288. success: false,
  289. message: 'Cannot erase data for administrator accounts. Please change their role first.'
  290. }, status: :unprocessable_entity
  291. end
  292. end
  293. end
  294. end
  295. end

app/controllers/api/v1/image_optimization_controller.rb

0.0% lines covered

100.0% branches covered

196 relevant lines. 0 lines covered and 196 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::ImageOptimizationController < Api::V1::BaseController
  2. before_action :authenticate_user!
  3. # GET /api/v1/image_optimization/analytics
  4. def analytics
  5. @stats = calculate_overview_stats
  6. @recent_optimizations = ImageOptimizationLog.recent.limit(50).includes(:medium, :upload, :user)
  7. @compression_level_stats = ImageOptimizationLog.compression_level_stats
  8. @optimization_type_stats = ImageOptimizationLog.optimization_type_stats
  9. render json: {
  10. success: true,
  11. data: {
  12. overview: @stats,
  13. recent_optimizations: @recent_optimizations.map(&:api_response),
  14. compression_level_stats: @compression_level_stats,
  15. optimization_type_stats: @optimization_type_stats
  16. }
  17. }
  18. end
  19. # GET /api/v1/image_optimization/report
  20. def report
  21. start_date = params[:start_date]&.to_date || 30.days.ago.to_date
  22. end_date = params[:end_date]&.to_date || Date.current
  23. @report = ImageOptimizationLog.generate_report(start_date, end_date)
  24. render json: {
  25. success: true,
  26. data: {
  27. report: @report,
  28. date_range: {
  29. start_date: start_date,
  30. end_date: end_date
  31. }
  32. }
  33. }
  34. end
  35. # GET /api/v1/image_optimization/failed
  36. def failed
  37. @failed_optimizations = ImageOptimizationLog.failed_optimizations
  38. .includes(:medium, :upload, :user)
  39. .page(params[:page])
  40. .per(params[:per_page] || 20)
  41. render json: {
  42. success: true,
  43. data: {
  44. failed_optimizations: @failed_optimizations.map(&:api_response),
  45. pagination: {
  46. current_page: @failed_optimizations.current_page,
  47. total_pages: @failed_optimizations.total_pages,
  48. total_count: @failed_optimizations.total_count
  49. }
  50. }
  51. }
  52. end
  53. # GET /api/v1/image_optimization/top_savings
  54. def top_savings
  55. limit = params[:limit]&.to_i || 50
  56. @top_savings = ImageOptimizationLog.top_savings(limit).includes(:medium, :upload, :user)
  57. render json: {
  58. success: true,
  59. data: {
  60. top_savings: @top_savings.map(&:api_response)
  61. }
  62. }
  63. end
  64. # GET /api/v1/image_optimization/user_stats
  65. def user_stats
  66. @user_stats = ImageOptimizationLog.user_stats
  67. @top_users = @user_stats.sort_by { |_, count| -count }.first(20)
  68. render json: {
  69. success: true,
  70. data: {
  71. user_stats: @user_stats,
  72. top_users: @top_users
  73. }
  74. }
  75. end
  76. # GET /api/v1/image_optimization/compression_levels
  77. def compression_levels
  78. @compression_levels = ImageOptimizationService.available_compression_levels
  79. @level_stats = ImageOptimizationLog.compression_level_stats
  80. render json: {
  81. success: true,
  82. data: {
  83. available_levels: @compression_levels,
  84. usage_stats: @level_stats
  85. }
  86. }
  87. end
  88. # GET /api/v1/image_optimization/performance
  89. def performance
  90. @avg_processing_time = ImageOptimizationLog.average_processing_time
  91. @avg_size_reduction = ImageOptimizationLog.average_size_reduction
  92. @total_processing_time = ImageOptimizationLog.total_processing_time
  93. @total_bytes_saved = ImageOptimizationLog.total_bytes_saved
  94. render json: {
  95. success: true,
  96. data: {
  97. average_processing_time: @avg_processing_time,
  98. average_size_reduction: @avg_size_reduction,
  99. total_processing_time: @total_processing_time,
  100. total_bytes_saved: @total_bytes_saved,
  101. total_size_saved_mb: (@total_bytes_saved / 1024.0 / 1024.0).round(2)
  102. }
  103. }
  104. end
  105. # POST /api/v1/image_optimization/bulk_optimize
  106. def bulk_optimize
  107. # Get all unoptimized images
  108. unoptimized_uploads = Upload.joins(:media)
  109. .where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
  110. .where.not(file: nil)
  111. if unoptimized_uploads.empty?
  112. render json: {
  113. success: true,
  114. message: 'No unoptimized images found',
  115. data: { queued_count: 0 }
  116. }
  117. return
  118. end
  119. # Queue optimization jobs
  120. queued_count = 0
  121. unoptimized_uploads.limit(100).each do |upload|
  122. medium = upload.media.first
  123. if medium
  124. OptimizeImageJob.perform_later(
  125. medium_id: medium.id,
  126. optimization_type: 'bulk',
  127. request_context: {
  128. user_agent: request.user_agent,
  129. ip_address: request.remote_ip
  130. }
  131. )
  132. queued_count += 1
  133. end
  134. end
  135. render json: {
  136. success: true,
  137. message: "Queued #{queued_count} images for optimization",
  138. data: { queued_count: queued_count }
  139. }
  140. end
  141. # POST /api/v1/image_optimization/regenerate_variants
  142. def regenerate_variants
  143. medium_id = params[:medium_id]
  144. if medium_id
  145. medium = Medium.find(medium_id)
  146. OptimizeImageJob.perform_later(
  147. medium_id: medium.id,
  148. optimization_type: 'regenerate',
  149. request_context: {
  150. user_agent: request.user_agent,
  151. ip_address: request.remote_ip
  152. }
  153. )
  154. render json: {
  155. success: true,
  156. message: "Queued variant regeneration for medium #{medium_id}",
  157. data: { medium_id: medium_id }
  158. }
  159. else
  160. render json: {
  161. success: false,
  162. message: 'medium_id parameter is required'
  163. }, status: 400
  164. end
  165. end
  166. # DELETE /api/v1/image_optimization/clear_logs
  167. def clear_logs
  168. if params[:confirm] == 'yes'
  169. ImageOptimizationLog.delete_all
  170. render json: {
  171. success: true,
  172. message: 'All optimization logs have been cleared'
  173. }
  174. else
  175. render json: {
  176. success: false,
  177. message: 'Log clearing cancelled. Use confirm=yes to clear logs.'
  178. }, status: 400
  179. end
  180. end
  181. # GET /api/v1/image_optimization/export
  182. def export
  183. start_date = params[:start_date]&.to_date || 30.days.ago.to_date
  184. end_date = params[:end_date]&.to_date || Date.current
  185. csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
  186. send_data csv_data,
  187. filename: "image_optimization_export_#{start_date}_to_#{end_date}.csv",
  188. type: 'text/csv'
  189. end
  190. private
  191. def calculate_overview_stats
  192. total_bytes_saved = ImageOptimizationLog.total_bytes_saved || 0
  193. avg_reduction = ImageOptimizationLog.average_size_reduction || 0
  194. avg_processing = ImageOptimizationLog.average_processing_time || 0
  195. {
  196. total_optimizations: ImageOptimizationLog.count,
  197. successful_optimizations: ImageOptimizationLog.successful.count,
  198. failed_optimizations: ImageOptimizationLog.failed.count,
  199. skipped_optimizations: ImageOptimizationLog.skipped.count,
  200. total_bytes_saved: total_bytes_saved,
  201. total_size_saved_mb: (total_bytes_saved / 1024.0 / 1024.0).round(2),
  202. average_size_reduction: avg_reduction.round(2),
  203. average_processing_time: avg_processing.round(3),
  204. today_optimizations: ImageOptimizationLog.today.count,
  205. this_week_optimizations: ImageOptimizationLog.this_week.count,
  206. this_month_optimizations: ImageOptimizationLog.this_month.count
  207. }
  208. end
  209. end

app/controllers/api/v1/mcp_controller.rb

0.0% lines covered

100.0% branches covered

1728 relevant lines. 0 lines covered and 1728 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Server-Sent Events helper class
  2. class SSE
  3. def initialize(stream, options = {})
  4. @stream = stream
  5. @retry_interval = options[:retry] || 300
  6. @event = options[:event]
  7. end
  8. def write(data, options = {})
  9. event = options[:event] || @event
  10. id = options[:id]
  11. retry_interval = options[:retry] || @retry_interval
  12. # Write event type
  13. @stream.write("event: #{event}\n") if event
  14. # Write event ID
  15. @stream.write("id: #{id}\n") if id
  16. # Write retry interval
  17. @stream.write("retry: #{retry_interval}\n") if retry_interval
  18. # Write data
  19. if data.is_a?(String)
  20. @stream.write("data: #{data}\n\n")
  21. else
  22. @stream.write("data: #{data.to_json}\n\n")
  23. end
  24. @stream.flush
  25. end
  26. def close
  27. @stream.close
  28. end
  29. end
  30. module Api
  31. module V1
  32. class McpController < BaseController
  33. before_action :authenticate_api_user!, only: [:tools_call]
  34. # POST /api/v1/mcp/session/handshake
  35. def handshake
  36. body = request.body.read
  37. if body.blank?
  38. return render_jsonrpc_error(-32700, 'Parse error', nil)
  39. end
  40. request_data = JSON.parse(body)
  41. # Validate JSON-RPC format
  42. unless request_data['jsonrpc'] == '2.0' && request_data['method'] == 'session/handshake'
  43. return render_jsonrpc_error(-32600, 'Invalid Request', request_data['id'])
  44. end
  45. # Validate protocol version
  46. protocol_version = request_data.dig('params', 'protocolVersion')
  47. unless protocol_version == '2025-03-26'
  48. return render_jsonrpc_error(-32602, 'Invalid protocol version', request_data['id'])
  49. end
  50. # Respond with server capabilities
  51. response_data = {
  52. jsonrpc: '2.0',
  53. result: {
  54. protocolVersion: '2025-03-26',
  55. capabilities: ['tools', 'resources', 'prompts'],
  56. serverInfo: {
  57. name: 'railspress-mcp-server',
  58. version: '1.0.0'
  59. }
  60. },
  61. id: request_data['id']
  62. }
  63. render json: response_data
  64. end
  65. # GET /api/v1/mcp/tools/list
  66. def tools_list
  67. tools = [
  68. {
  69. name: 'get_posts',
  70. description: 'Retrieve posts with optional filtering',
  71. inputSchema: {
  72. type: 'object',
  73. properties: {
  74. status: { type: 'string', enum: ['published', 'draft', 'pending_review', 'scheduled', 'trash'] },
  75. limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
  76. offset: { type: 'integer', minimum: 0, default: 0 },
  77. search: { type: 'string', description: 'Search in title and content' },
  78. category: { type: 'string', description: 'Filter by category slug' },
  79. tag: { type: 'string', description: 'Filter by tag slug' },
  80. author: { type: 'integer', description: 'Filter by author ID' },
  81. date_from: { type: 'string', format: 'date' },
  82. date_to: { type: 'string', format: 'date' }
  83. }
  84. },
  85. outputSchema: {
  86. type: 'object',
  87. properties: {
  88. posts: {
  89. type: 'array',
  90. items: {
  91. type: 'object',
  92. properties: {
  93. id: { type: 'integer' },
  94. title: { type: 'string' },
  95. slug: { type: 'string' },
  96. content: { type: 'string' },
  97. excerpt: { type: 'string' },
  98. status: { type: 'string' },
  99. published_at: { type: 'string', format: 'date-time' },
  100. created_at: { type: 'string', format: 'date-time' },
  101. updated_at: { type: 'string', format: 'date-time' },
  102. author: { type: 'object' },
  103. categories: { type: 'array' },
  104. tags: { type: 'array' },
  105. meta_fields: { type: 'object' }
  106. }
  107. }
  108. },
  109. total: { type: 'integer' },
  110. limit: { type: 'integer' },
  111. offset: { type: 'integer' }
  112. }
  113. }
  114. },
  115. {
  116. name: 'get_post',
  117. description: 'Retrieve a single post by ID or slug',
  118. inputSchema: {
  119. type: 'object',
  120. properties: {
  121. id: { type: 'integer', description: 'Post ID' },
  122. slug: { type: 'string', description: 'Post slug' }
  123. },
  124. anyOf: [
  125. { required: ['id'] },
  126. { required: ['slug'] }
  127. ]
  128. },
  129. outputSchema: {
  130. type: 'object',
  131. properties: {
  132. post: {
  133. type: 'object',
  134. properties: {
  135. id: { type: 'integer' },
  136. title: { type: 'string' },
  137. slug: { type: 'string' },
  138. content: { type: 'string' },
  139. excerpt: { type: 'string' },
  140. status: { type: 'string' },
  141. published_at: { type: 'string', format: 'date-time' },
  142. created_at: { type: 'string', format: 'date-time' },
  143. updated_at: { type: 'string', format: 'date-time' },
  144. author: { type: 'object' },
  145. categories: { type: 'array' },
  146. tags: { type: 'array' },
  147. meta_fields: { type: 'object' },
  148. comments: { type: 'array' }
  149. }
  150. }
  151. }
  152. }
  153. },
  154. {
  155. name: 'create_post',
  156. description: 'Create a new post',
  157. inputSchema: {
  158. type: 'object',
  159. properties: {
  160. title: { type: 'string', minLength: 1 },
  161. content: { type: 'string' },
  162. excerpt: { type: 'string' },
  163. status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled'], default: 'draft' },
  164. published_at: { type: 'string', format: 'date-time' },
  165. slug: { type: 'string' },
  166. meta_title: { type: 'string' },
  167. meta_description: { type: 'string' },
  168. category_ids: { type: 'array', items: { type: 'integer' } },
  169. tag_ids: { type: 'array', items: { type: 'integer' } },
  170. meta_fields: { type: 'object' }
  171. },
  172. required: ['title']
  173. },
  174. outputSchema: {
  175. type: 'object',
  176. properties: {
  177. post: {
  178. type: 'object',
  179. properties: {
  180. id: { type: 'integer' },
  181. title: { type: 'string' },
  182. slug: { type: 'string' },
  183. content: { type: 'string' },
  184. excerpt: { type: 'string' },
  185. status: { type: 'string' },
  186. published_at: { type: 'string', format: 'date-time' },
  187. created_at: { type: 'string', format: 'date-time' },
  188. updated_at: { type: 'string', format: 'date-time' }
  189. }
  190. }
  191. }
  192. }
  193. },
  194. {
  195. name: 'update_post',
  196. description: 'Update an existing post',
  197. inputSchema: {
  198. type: 'object',
  199. properties: {
  200. id: { type: 'integer' },
  201. title: { type: 'string' },
  202. content: { type: 'string' },
  203. excerpt: { type: 'string' },
  204. status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled'] },
  205. published_at: { type: 'string', format: 'date-time' },
  206. slug: { type: 'string' },
  207. meta_title: { type: 'string' },
  208. meta_description: { type: 'string' },
  209. category_ids: { type: 'array', items: { type: 'integer' } },
  210. tag_ids: { type: 'array', items: { type: 'integer' } },
  211. meta_fields: { type: 'object' }
  212. },
  213. required: ['id']
  214. },
  215. outputSchema: {
  216. type: 'object',
  217. properties: {
  218. post: {
  219. type: 'object',
  220. properties: {
  221. id: { type: 'integer' },
  222. title: { type: 'string' },
  223. slug: { type: 'string' },
  224. content: { type: 'string' },
  225. excerpt: { type: 'string' },
  226. status: { type: 'string' },
  227. published_at: { type: 'string', format: 'date-time' },
  228. created_at: { type: 'string', format: 'date-time' },
  229. updated_at: { type: 'string', format: 'date-time' }
  230. }
  231. }
  232. }
  233. }
  234. },
  235. {
  236. name: 'delete_post',
  237. description: 'Delete a post (move to trash)',
  238. inputSchema: {
  239. type: 'object',
  240. properties: {
  241. id: { type: 'integer' }
  242. },
  243. required: ['id']
  244. },
  245. outputSchema: {
  246. type: 'object',
  247. properties: {
  248. success: { type: 'boolean' },
  249. message: { type: 'string' }
  250. }
  251. }
  252. },
  253. {
  254. name: 'get_pages',
  255. description: 'Retrieve pages with optional filtering',
  256. inputSchema: {
  257. type: 'object',
  258. properties: {
  259. status: { type: 'string', enum: ['published', 'draft', 'pending_review', 'scheduled', 'private_page', 'trash'] },
  260. limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
  261. offset: { type: 'integer', minimum: 0, default: 0 },
  262. search: { type: 'string', description: 'Search in title and content' },
  263. parent_id: { type: 'integer', description: 'Filter by parent page ID' },
  264. root_only: { type: 'boolean', description: 'Only root pages' },
  265. channel: { type: 'string', description: 'Filter by channel slug' }
  266. }
  267. },
  268. outputSchema: {
  269. type: 'object',
  270. properties: {
  271. pages: {
  272. type: 'array',
  273. items: {
  274. type: 'object',
  275. properties: {
  276. id: { type: 'integer' },
  277. title: { type: 'string' },
  278. slug: { type: 'string' },
  279. content: { type: 'string' },
  280. excerpt: { type: 'string' },
  281. status: { type: 'string' },
  282. published_at: { type: 'string', format: 'date-time' },
  283. created_at: { type: 'string', format: 'date-time' },
  284. updated_at: { type: 'string', format: 'date-time' },
  285. author: { type: 'object' },
  286. parent: { type: 'object' },
  287. children: { type: 'array' },
  288. meta_fields: { type: 'object' }
  289. }
  290. }
  291. },
  292. total: { type: 'integer' },
  293. limit: { type: 'integer' },
  294. offset: { type: 'integer' }
  295. }
  296. }
  297. },
  298. {
  299. name: 'get_page',
  300. description: 'Retrieve a single page by ID or slug',
  301. inputSchema: {
  302. type: 'object',
  303. properties: {
  304. id: { type: 'integer', description: 'Page ID' },
  305. slug: { type: 'string', description: 'Page slug' }
  306. },
  307. anyOf: [
  308. { required: ['id'] },
  309. { required: ['slug'] }
  310. ]
  311. },
  312. outputSchema: {
  313. type: 'object',
  314. properties: {
  315. page: {
  316. type: 'object',
  317. properties: {
  318. id: { type: 'integer' },
  319. title: { type: 'string' },
  320. slug: { type: 'string' },
  321. content: { type: 'string' },
  322. excerpt: { type: 'string' },
  323. status: { type: 'string' },
  324. published_at: { type: 'string', format: 'date-time' },
  325. created_at: { type: 'string', format: 'date-time' },
  326. updated_at: { type: 'string', format: 'date-time' },
  327. author: { type: 'object' },
  328. parent: { type: 'object' },
  329. children: { type: 'array' },
  330. meta_fields: { type: 'object' },
  331. comments: { type: 'array' }
  332. }
  333. }
  334. }
  335. }
  336. },
  337. {
  338. name: 'create_page',
  339. description: 'Create a new page',
  340. inputSchema: {
  341. type: 'object',
  342. properties: {
  343. title: { type: 'string', minLength: 1 },
  344. content: { type: 'string' },
  345. excerpt: { type: 'string' },
  346. status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled', 'private_page'], default: 'draft' },
  347. published_at: { type: 'string', format: 'date-time' },
  348. slug: { type: 'string' },
  349. parent_id: { type: 'integer' },
  350. meta_title: { type: 'string' },
  351. meta_description: { type: 'string' },
  352. meta_fields: { type: 'object' }
  353. },
  354. required: ['title']
  355. },
  356. outputSchema: {
  357. type: 'object',
  358. properties: {
  359. page: {
  360. type: 'object',
  361. properties: {
  362. id: { type: 'integer' },
  363. title: { type: 'string' },
  364. slug: { type: 'string' },
  365. content: { type: 'string' },
  366. excerpt: { type: 'string' },
  367. status: { type: 'string' },
  368. published_at: { type: 'string', format: 'date-time' },
  369. created_at: { type: 'string', format: 'date-time' },
  370. updated_at: { type: 'string', format: 'date-time' }
  371. }
  372. }
  373. }
  374. }
  375. },
  376. {
  377. name: 'update_page',
  378. description: 'Update an existing page',
  379. inputSchema: {
  380. type: 'object',
  381. properties: {
  382. id: { type: 'integer' },
  383. title: { type: 'string' },
  384. content: { type: 'string' },
  385. excerpt: { type: 'string' },
  386. status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled', 'private_page'] },
  387. published_at: { type: 'string', format: 'date-time' },
  388. slug: { type: 'string' },
  389. parent_id: { type: 'integer' },
  390. meta_title: { type: 'string' },
  391. meta_description: { type: 'string' },
  392. meta_fields: { type: 'object' }
  393. },
  394. required: ['id']
  395. },
  396. outputSchema: {
  397. type: 'object',
  398. properties: {
  399. page: {
  400. type: 'object',
  401. properties: {
  402. id: { type: 'integer' },
  403. title: { type: 'string' },
  404. slug: { type: 'string' },
  405. content: { type: 'string' },
  406. excerpt: { type: 'string' },
  407. status: { type: 'string' },
  408. published_at: { type: 'string', format: 'date-time' },
  409. created_at: { type: 'string', format: 'date-time' },
  410. updated_at: { type: 'string', format: 'date-time' }
  411. }
  412. }
  413. }
  414. }
  415. },
  416. {
  417. name: 'delete_page',
  418. description: 'Delete a page (move to trash)',
  419. inputSchema: {
  420. type: 'object',
  421. properties: {
  422. id: { type: 'integer' }
  423. },
  424. required: ['id']
  425. },
  426. outputSchema: {
  427. type: 'object',
  428. properties: {
  429. success: { type: 'boolean' },
  430. message: { type: 'string' }
  431. }
  432. }
  433. },
  434. {
  435. name: 'get_taxonomies',
  436. description: 'Retrieve all taxonomies',
  437. inputSchema: {
  438. type: 'object',
  439. properties: {
  440. hierarchical: { type: 'boolean', description: 'Filter by hierarchical type' },
  441. object_types: { type: 'array', items: { type: 'string' }, description: 'Filter by object types' }
  442. }
  443. },
  444. outputSchema: {
  445. type: 'object',
  446. properties: {
  447. taxonomies: {
  448. type: 'array',
  449. items: {
  450. type: 'object',
  451. properties: {
  452. id: { type: 'integer' },
  453. name: { type: 'string' },
  454. slug: { type: 'string' },
  455. description: { type: 'string' },
  456. hierarchical: { type: 'boolean' },
  457. object_types: { type: 'array' },
  458. term_count: { type: 'integer' },
  459. settings: { type: 'object' }
  460. }
  461. }
  462. }
  463. }
  464. }
  465. },
  466. {
  467. name: 'get_terms',
  468. description: 'Retrieve terms for a taxonomy',
  469. inputSchema: {
  470. type: 'object',
  471. properties: {
  472. taxonomy: { type: 'string', description: 'Taxonomy slug (e.g., category, post_tag)' },
  473. parent_id: { type: 'integer', description: 'Filter by parent term ID' },
  474. root_only: { type: 'boolean', description: 'Only root terms' },
  475. search: { type: 'string', description: 'Search in term names' },
  476. limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
  477. offset: { type: 'integer', minimum: 0, default: 0 }
  478. },
  479. required: ['taxonomy']
  480. },
  481. outputSchema: {
  482. type: 'object',
  483. properties: {
  484. terms: {
  485. type: 'array',
  486. items: {
  487. type: 'object',
  488. properties: {
  489. id: { type: 'integer' },
  490. name: { type: 'string' },
  491. slug: { type: 'string' },
  492. description: { type: 'string' },
  493. count: { type: 'integer' },
  494. parent_id: { type: 'integer' },
  495. taxonomy: { type: 'object' },
  496. children: { type: 'array' }
  497. }
  498. }
  499. },
  500. total: { type: 'integer' },
  501. limit: { type: 'integer' },
  502. offset: { type: 'integer' }
  503. }
  504. }
  505. },
  506. {
  507. name: 'create_term',
  508. description: 'Create a new term',
  509. inputSchema: {
  510. type: 'object',
  511. properties: {
  512. name: { type: 'string', minLength: 1 },
  513. taxonomy: { type: 'string', description: 'Taxonomy slug' },
  514. description: { type: 'string' },
  515. parent_id: { type: 'integer' },
  516. slug: { type: 'string' },
  517. metadata: { type: 'object' }
  518. },
  519. required: ['name', 'taxonomy']
  520. },
  521. outputSchema: {
  522. type: 'object',
  523. properties: {
  524. term: {
  525. type: 'object',
  526. properties: {
  527. id: { type: 'integer' },
  528. name: { type: 'string' },
  529. slug: { type: 'string' },
  530. description: { type: 'string' },
  531. count: { type: 'integer' },
  532. parent_id: { type: 'integer' },
  533. taxonomy: { type: 'object' }
  534. }
  535. }
  536. }
  537. }
  538. },
  539. {
  540. name: 'update_term',
  541. description: 'Update an existing term',
  542. inputSchema: {
  543. type: 'object',
  544. properties: {
  545. id: { type: 'integer' },
  546. name: { type: 'string' },
  547. description: { type: 'string' },
  548. parent_id: { type: 'integer' },
  549. slug: { type: 'string' },
  550. metadata: { type: 'object' }
  551. },
  552. required: ['id']
  553. },
  554. outputSchema: {
  555. type: 'object',
  556. properties: {
  557. term: {
  558. type: 'object',
  559. properties: {
  560. id: { type: 'integer' },
  561. name: { type: 'string' },
  562. slug: { type: 'string' },
  563. description: { type: 'string' },
  564. count: { type: 'integer' },
  565. parent_id: { type: 'integer' },
  566. taxonomy: { type: 'object' }
  567. }
  568. }
  569. }
  570. }
  571. },
  572. {
  573. name: 'delete_term',
  574. description: 'Delete a term',
  575. inputSchema: {
  576. type: 'object',
  577. properties: {
  578. id: { type: 'integer' }
  579. },
  580. required: ['id']
  581. },
  582. outputSchema: {
  583. type: 'object',
  584. properties: {
  585. success: { type: 'boolean' },
  586. message: { type: 'string' }
  587. }
  588. }
  589. },
  590. {
  591. name: 'get_content_types',
  592. description: 'Retrieve all content types',
  593. inputSchema: {
  594. type: 'object',
  595. properties: {}
  596. },
  597. outputSchema: {
  598. type: 'object',
  599. properties: {
  600. content_types: {
  601. type: 'array',
  602. items: {
  603. type: 'object',
  604. properties: {
  605. id: { type: 'integer' },
  606. name: { type: 'string' },
  607. slug: { type: 'string' },
  608. description: { type: 'string' },
  609. icon: { type: 'string' },
  610. supports: { type: 'array' },
  611. labels: { type: 'object' },
  612. capabilities: { type: 'object' },
  613. settings: { type: 'object' }
  614. }
  615. }
  616. }
  617. }
  618. }
  619. },
  620. {
  621. name: 'get_media',
  622. description: 'Retrieve media files',
  623. inputSchema: {
  624. type: 'object',
  625. properties: {
  626. limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
  627. offset: { type: 'integer', minimum: 0, default: 0 },
  628. search: { type: 'string', description: 'Search in filename and title' },
  629. mime_type: { type: 'string', description: 'Filter by MIME type' },
  630. uploaded_by: { type: 'integer', description: 'Filter by uploader ID' },
  631. date_from: { type: 'string', format: 'date' },
  632. date_to: { type: 'string', format: 'date' }
  633. }
  634. },
  635. outputSchema: {
  636. type: 'object',
  637. properties: {
  638. media: {
  639. type: 'array',
  640. items: {
  641. type: 'object',
  642. properties: {
  643. id: { type: 'integer' },
  644. filename: { type: 'string' },
  645. title: { type: 'string' },
  646. alt_text: { type: 'string' },
  647. caption: { type: 'string' },
  648. description: { type: 'string' },
  649. mime_type: { type: 'string' },
  650. file_size: { type: 'integer' },
  651. url: { type: 'string' },
  652. thumbnail_url: { type: 'string' },
  653. uploaded_at: { type: 'string', format: 'date-time' },
  654. uploaded_by: { type: 'object' }
  655. }
  656. }
  657. },
  658. total: { type: 'integer' },
  659. limit: { type: 'integer' },
  660. offset: { type: 'integer' }
  661. }
  662. }
  663. },
  664. {
  665. name: 'upload_media',
  666. description: 'Upload a media file',
  667. inputSchema: {
  668. type: 'object',
  669. properties: {
  670. file: { type: 'string', description: 'Base64 encoded file data' },
  671. filename: { type: 'string' },
  672. title: { type: 'string' },
  673. alt_text: { type: 'string' },
  674. caption: { type: 'string' },
  675. description: { type: 'string' }
  676. },
  677. required: ['file', 'filename']
  678. },
  679. outputSchema: {
  680. type: 'object',
  681. properties: {
  682. media: {
  683. type: 'object',
  684. properties: {
  685. id: { type: 'integer' },
  686. filename: { type: 'string' },
  687. title: { type: 'string' },
  688. alt_text: { type: 'string' },
  689. caption: { type: 'string' },
  690. description: { type: 'string' },
  691. mime_type: { type: 'string' },
  692. file_size: { type: 'integer' },
  693. url: { type: 'string' },
  694. thumbnail_url: { type: 'string' },
  695. uploaded_at: { type: 'string', format: 'date-time' }
  696. }
  697. }
  698. }
  699. }
  700. },
  701. {
  702. name: 'get_users',
  703. description: 'Retrieve users',
  704. inputSchema: {
  705. type: 'object',
  706. properties: {
  707. limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
  708. offset: { type: 'integer', minimum: 0, default: 0 },
  709. search: { type: 'string', description: 'Search in name and email' },
  710. role: { type: 'string', description: 'Filter by role' },
  711. status: { type: 'string', enum: ['active', 'inactive'] }
  712. }
  713. },
  714. outputSchema: {
  715. type: 'object',
  716. properties: {
  717. users: {
  718. type: 'array',
  719. items: {
  720. type: 'object',
  721. properties: {
  722. id: { type: 'integer' },
  723. name: { type: 'string' },
  724. email: { type: 'string' },
  725. role: { type: 'string' },
  726. status: { type: 'string' },
  727. created_at: { type: 'string', format: 'date-time' },
  728. last_login_at: { type: 'string', format: 'date-time' }
  729. }
  730. }
  731. },
  732. total: { type: 'integer' },
  733. limit: { type: 'integer' },
  734. offset: { type: 'integer' }
  735. }
  736. }
  737. },
  738. {
  739. name: 'get_system_info',
  740. description: 'Get system information and statistics',
  741. inputSchema: {
  742. type: 'object',
  743. properties: {}
  744. },
  745. outputSchema: {
  746. type: 'object',
  747. properties: {
  748. system: {
  749. type: 'object',
  750. properties: {
  751. name: { type: 'string' },
  752. version: { type: 'string' },
  753. rails_version: { type: 'string' },
  754. ruby_version: { type: 'string' },
  755. environment: { type: 'string' },
  756. statistics: {
  757. type: 'object',
  758. properties: {
  759. posts_count: { type: 'integer' },
  760. pages_count: { type: 'integer' },
  761. users_count: { type: 'integer' },
  762. media_count: { type: 'integer' },
  763. comments_count: { type: 'integer' }
  764. }
  765. }
  766. }
  767. }
  768. }
  769. }
  770. }
  771. ]
  772. render_jsonrpc_success({ tools: tools })
  773. end
  774. # POST /api/v1/mcp/tools/call
  775. def tools_call
  776. body = request.body.read
  777. if body.blank?
  778. return render_jsonrpc_error(-32700, 'Parse error', nil)
  779. end
  780. request_data = JSON.parse(body)
  781. unless request_data['jsonrpc'] == '2.0' && request_data['method'] == 'tools/call'
  782. return render_jsonrpc_error(-32600, 'Invalid Request', request_data['id'])
  783. end
  784. tool_name = request_data.dig('params', 'name')
  785. arguments = request_data.dig('params', 'arguments') || {}
  786. result = execute_tool(tool_name, arguments)
  787. if result[:success]
  788. render_jsonrpc_success({
  789. content: [
  790. {
  791. type: 'output',
  792. data: result[:data]
  793. }
  794. ]
  795. }, request_data['id'])
  796. else
  797. render_jsonrpc_error(-32603, result[:error], request_data['id'])
  798. end
  799. rescue => e
  800. Rails.logger.error "MCP Tool Error: #{e.message}"
  801. render_jsonrpc_error(-32603, "Internal error: #{e.message}", request_data['id'])
  802. end
  803. # GET /api/v1/mcp/tools/stream
  804. def tools_stream
  805. response.headers['Content-Type'] = 'text/event-stream'
  806. response.headers['Cache-Control'] = 'no-cache'
  807. response.headers['Connection'] = 'keep-alive'
  808. tool_name = params[:tool]
  809. arguments = JSON.parse(params[:arguments] || '{}')
  810. sse = SSE.new(response.stream, retry: 300, event: "message")
  811. begin
  812. # Send initial progress
  813. sse.write({ progress: 0.1, message: "Starting #{tool_name}" }, event: 'tools/update')
  814. # Execute tool with streaming updates
  815. result = execute_tool_with_streaming(tool_name, arguments) do |progress, partial_data|
  816. sse.write({
  817. tool: tool_name,
  818. progress: progress,
  819. partial: partial_data
  820. }, event: 'tools/update')
  821. end
  822. # Send final result
  823. sse.write({
  824. tool: tool_name,
  825. content: [
  826. {
  827. type: 'output',
  828. data: result[:data]
  829. }
  830. ]
  831. }, event: 'tools/complete')
  832. rescue => e
  833. sse.write({
  834. tool: tool_name,
  835. error: e.message
  836. }, event: 'tools/error')
  837. ensure
  838. sse.close
  839. end
  840. end
  841. # GET /api/v1/mcp/resources/list
  842. def resources_list
  843. resources = [
  844. {
  845. uri: 'railspress://posts',
  846. name: 'Posts Collection',
  847. description: 'All posts in the system',
  848. mimeType: 'application/json'
  849. },
  850. {
  851. uri: 'railspress://pages',
  852. name: 'Pages Collection',
  853. description: 'All pages in the system',
  854. mimeType: 'application/json'
  855. },
  856. {
  857. uri: 'railspress://taxonomies',
  858. name: 'Taxonomies Collection',
  859. description: 'All taxonomies in the system',
  860. mimeType: 'application/json'
  861. },
  862. {
  863. uri: 'railspress://terms',
  864. name: 'Terms Collection',
  865. description: 'All terms in the system',
  866. mimeType: 'application/json'
  867. },
  868. {
  869. uri: 'railspress://media',
  870. name: 'Media Collection',
  871. description: 'All media files in the system',
  872. mimeType: 'application/json'
  873. },
  874. {
  875. uri: 'railspress://users',
  876. name: 'Users Collection',
  877. description: 'All users in the system',
  878. mimeType: 'application/json'
  879. },
  880. {
  881. uri: 'railspress://content-types',
  882. name: 'Content Types Collection',
  883. description: 'All content types in the system',
  884. mimeType: 'application/json'
  885. }
  886. ]
  887. render_jsonrpc_success({ resources: resources })
  888. end
  889. # GET /api/v1/mcp/prompts/list
  890. def prompts_list
  891. prompts = [
  892. {
  893. name: 'seo_optimize',
  894. description: 'Optimize content for SEO',
  895. arguments: [
  896. {
  897. name: 'content',
  898. description: 'The content to optimize',
  899. required: true
  900. },
  901. {
  902. name: 'target_keywords',
  903. description: 'Target keywords for SEO',
  904. required: false
  905. },
  906. {
  907. name: 'content_type',
  908. description: 'Type of content (post, page)',
  909. required: false
  910. }
  911. ]
  912. },
  913. {
  914. name: 'content_summarize',
  915. description: 'Summarize content',
  916. arguments: [
  917. {
  918. name: 'content',
  919. description: 'The content to summarize',
  920. required: true
  921. },
  922. {
  923. name: 'max_length',
  924. description: 'Maximum length of summary',
  925. required: false
  926. }
  927. ]
  928. },
  929. {
  930. name: 'content_generate',
  931. description: 'Generate content based on topic',
  932. arguments: [
  933. {
  934. name: 'topic',
  935. description: 'Topic to generate content about',
  936. required: true
  937. },
  938. {
  939. name: 'content_type',
  940. description: 'Type of content to generate',
  941. required: false
  942. },
  943. {
  944. name: 'tone',
  945. description: 'Tone of the content',
  946. required: false
  947. },
  948. {
  949. name: 'length',
  950. description: 'Desired length of content',
  951. required: false
  952. }
  953. ]
  954. },
  955. {
  956. name: 'meta_description_generate',
  957. description: 'Generate meta description for content',
  958. arguments: [
  959. {
  960. name: 'title',
  961. description: 'Content title',
  962. required: true
  963. },
  964. {
  965. name: 'content',
  966. description: 'Content body',
  967. required: true
  968. },
  969. {
  970. name: 'keywords',
  971. description: 'Target keywords',
  972. required: false
  973. }
  974. ]
  975. }
  976. ]
  977. render_jsonrpc_success({ prompts: prompts })
  978. end
  979. private
  980. def execute_tool(tool_name, arguments)
  981. case tool_name
  982. when 'get_posts'
  983. execute_get_posts(arguments)
  984. when 'get_post'
  985. execute_get_post(arguments)
  986. when 'create_post'
  987. execute_create_post(arguments)
  988. when 'update_post'
  989. execute_update_post(arguments)
  990. when 'delete_post'
  991. execute_delete_post(arguments)
  992. when 'get_pages'
  993. execute_get_pages(arguments)
  994. when 'get_page'
  995. execute_get_page(arguments)
  996. when 'create_page'
  997. execute_create_page(arguments)
  998. when 'update_page'
  999. execute_update_page(arguments)
  1000. when 'delete_page'
  1001. execute_delete_page(arguments)
  1002. when 'get_taxonomies'
  1003. execute_get_taxonomies(arguments)
  1004. when 'get_terms'
  1005. execute_get_terms(arguments)
  1006. when 'create_term'
  1007. execute_create_term(arguments)
  1008. when 'update_term'
  1009. execute_update_term(arguments)
  1010. when 'delete_term'
  1011. execute_delete_term(arguments)
  1012. when 'get_content_types'
  1013. execute_get_content_types(arguments)
  1014. when 'get_media'
  1015. execute_get_media(arguments)
  1016. when 'upload_media'
  1017. execute_upload_media(arguments)
  1018. when 'get_users'
  1019. execute_get_users(arguments)
  1020. when 'get_system_info'
  1021. execute_get_system_info(arguments)
  1022. else
  1023. { success: false, error: "Unknown tool: #{tool_name}" }
  1024. end
  1025. end
  1026. def execute_tool_with_streaming(tool_name, arguments, &block)
  1027. case tool_name
  1028. when 'get_posts'
  1029. execute_get_posts_streaming(arguments, &block)
  1030. when 'get_media'
  1031. execute_get_media_streaming(arguments, &block)
  1032. else
  1033. # For tools that don't support streaming, execute normally
  1034. result = execute_tool(tool_name, arguments)
  1035. yield(0.5, { message: "Processing #{tool_name}" })
  1036. yield(1.0, result[:data])
  1037. result
  1038. end
  1039. end
  1040. # Tool implementations
  1041. def execute_get_posts(arguments)
  1042. posts = Post.all
  1043. # Apply filters
  1044. posts = posts.where(status: arguments['status']) if arguments['status'].present?
  1045. posts = posts.search_full_text(arguments['search']) if arguments['search'].present?
  1046. if arguments['category'].present?
  1047. category_term = Term.for_taxonomy('category').find_by(slug: arguments['category'])
  1048. posts = posts.joins(:term_relationships).where(term_relationships: { term_id: category_term.id }) if category_term
  1049. end
  1050. if arguments['tag'].present?
  1051. tag_term = Term.for_taxonomy('post_tag').find_by(slug: arguments['tag'])
  1052. posts = posts.joins(:term_relationships).where(term_relationships: { term_id: tag_term.id }) if tag_term
  1053. end
  1054. posts = posts.where(user_id: arguments['author']) if arguments['author'].present?
  1055. if arguments['date_from'].present?
  1056. posts = posts.where('published_at >= ?', Date.parse(arguments['date_from']))
  1057. end
  1058. if arguments['date_to'].present?
  1059. posts = posts.where('published_at <= ?', Date.parse(arguments['date_to']))
  1060. end
  1061. # Pagination
  1062. limit = arguments['limit'] || 20
  1063. offset = arguments['offset'] || 0
  1064. total = posts.count
  1065. posts = posts.limit(limit).offset(offset).order(created_at: :desc)
  1066. {
  1067. success: true,
  1068. data: {
  1069. posts: posts.map { |post| serialize_post(post) },
  1070. total: total,
  1071. limit: limit,
  1072. offset: offset
  1073. }
  1074. }
  1075. end
  1076. def execute_get_posts_streaming(arguments, &block)
  1077. yield(0.1, { message: "Fetching posts..." })
  1078. posts = Post.all
  1079. yield(0.3, { message: "Applying filters..." })
  1080. # Apply same filters as execute_get_posts
  1081. posts = posts.where(status: arguments['status']) if arguments['status'].present?
  1082. posts = posts.search_full_text(arguments['search']) if arguments['search'].present?
  1083. yield(0.6, { message: "Counting total..." })
  1084. total = posts.count
  1085. yield(0.8, { message: "Serializing data..." })
  1086. limit = arguments['limit'] || 20
  1087. offset = arguments['offset'] || 0
  1088. posts = posts.limit(limit).offset(offset).order(created_at: :desc)
  1089. {
  1090. success: true,
  1091. data: {
  1092. posts: posts.map { |post| serialize_post(post) },
  1093. total: total,
  1094. limit: limit,
  1095. offset: offset
  1096. }
  1097. }
  1098. end
  1099. def execute_get_post(arguments)
  1100. if arguments['id'].present?
  1101. post = Post.find(arguments['id'])
  1102. elsif arguments['slug'].present?
  1103. post = Post.find_by(slug: arguments['slug'])
  1104. else
  1105. return { success: false, error: 'Either id or slug must be provided' }
  1106. end
  1107. unless post
  1108. return { success: false, error: 'Post not found' }
  1109. end
  1110. {
  1111. success: true,
  1112. data: {
  1113. post: serialize_post(post, detailed: true)
  1114. }
  1115. }
  1116. end
  1117. def execute_create_post(arguments)
  1118. unless current_api_user&.can_create_posts?
  1119. return { success: false, error: 'You do not have permission to create posts' }
  1120. end
  1121. post = current_api_user.posts.build(
  1122. title: arguments['title'],
  1123. content: arguments['content'],
  1124. excerpt: arguments['excerpt'],
  1125. status: arguments['status'] || 'draft',
  1126. slug: arguments['slug'],
  1127. meta_title: arguments['meta_title'],
  1128. meta_description: arguments['meta_description']
  1129. )
  1130. if arguments['published_at'].present?
  1131. post.published_at = Time.parse(arguments['published_at'])
  1132. end
  1133. if post.save
  1134. # Handle categories and tags
  1135. if arguments['category_ids'].present?
  1136. post.category_ids = arguments['category_ids']
  1137. end
  1138. if arguments['tag_ids'].present?
  1139. post.tag_ids = arguments['tag_ids']
  1140. end
  1141. # Handle meta fields
  1142. if arguments['meta_fields'].present?
  1143. arguments['meta_fields'].each do |key, value|
  1144. post.set_meta(key, value)
  1145. end
  1146. end
  1147. {
  1148. success: true,
  1149. data: {
  1150. post: serialize_post(post)
  1151. }
  1152. }
  1153. else
  1154. {
  1155. success: false,
  1156. error: post.errors.full_messages.join(', ')
  1157. }
  1158. end
  1159. end
  1160. def execute_update_post(arguments)
  1161. post = Post.find(arguments['id'])
  1162. unless current_api_user&.can_edit_others_posts? || (current_api_user&.id == post.user_id)
  1163. return { success: false, error: 'You do not have permission to edit this post' }
  1164. end
  1165. update_params = arguments.except('id', 'category_ids', 'tag_ids', 'meta_fields')
  1166. if arguments['published_at'].present?
  1167. update_params['published_at'] = Time.parse(arguments['published_at'])
  1168. end
  1169. if post.update(update_params)
  1170. # Handle categories and tags
  1171. if arguments['category_ids'].present?
  1172. post.category_ids = arguments['category_ids']
  1173. end
  1174. if arguments['tag_ids'].present?
  1175. post.tag_ids = arguments['tag_ids']
  1176. end
  1177. # Handle meta fields
  1178. if arguments['meta_fields'].present?
  1179. arguments['meta_fields'].each do |key, value|
  1180. post.set_meta(key, value)
  1181. end
  1182. end
  1183. {
  1184. success: true,
  1185. data: {
  1186. post: serialize_post(post)
  1187. }
  1188. }
  1189. else
  1190. {
  1191. success: false,
  1192. error: post.errors.full_messages.join(', ')
  1193. }
  1194. end
  1195. end
  1196. def execute_delete_post(arguments)
  1197. post = Post.find(arguments['id'])
  1198. unless current_api_user&.can_delete_posts? || (current_api_user&.id == post.user_id)
  1199. return { success: false, error: 'You do not have permission to delete this post' }
  1200. end
  1201. post.discard
  1202. {
  1203. success: true,
  1204. data: {
  1205. success: true,
  1206. message: 'Post moved to trash'
  1207. }
  1208. }
  1209. end
  1210. def execute_get_pages(arguments)
  1211. pages = Page.all
  1212. # Apply filters
  1213. pages = pages.where(status: arguments['status']) if arguments['status'].present?
  1214. pages = pages.where(parent_id: arguments['parent_id']) if arguments['parent_id'].present?
  1215. pages = pages.root_pages if arguments['root_only'] == true
  1216. pages = pages.search_full_text(arguments['search']) if arguments['search'].present?
  1217. if arguments['channel'].present?
  1218. channel = Channel.find_by(slug: arguments['channel'])
  1219. if channel
  1220. pages = pages.left_joins(:channels)
  1221. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  1222. end
  1223. end
  1224. # Pagination
  1225. limit = arguments['limit'] || 20
  1226. offset = arguments['offset'] || 0
  1227. total = pages.count
  1228. pages = pages.limit(limit).offset(offset).order(order: :asc, created_at: :desc)
  1229. {
  1230. success: true,
  1231. data: {
  1232. pages: pages.map { |page| serialize_page(page) },
  1233. total: total,
  1234. limit: limit,
  1235. offset: offset
  1236. }
  1237. }
  1238. end
  1239. def execute_get_page(arguments)
  1240. if arguments['id'].present?
  1241. page = Page.find(arguments['id'])
  1242. elsif arguments['slug'].present?
  1243. page = Page.find_by(slug: arguments['slug'])
  1244. else
  1245. return { success: false, error: 'Either id or slug must be provided' }
  1246. end
  1247. unless page
  1248. return { success: false, error: 'Page not found' }
  1249. end
  1250. {
  1251. success: true,
  1252. data: {
  1253. page: serialize_page(page, detailed: true)
  1254. }
  1255. }
  1256. end
  1257. def execute_create_page(arguments)
  1258. unless current_api_user&.can_create_pages?
  1259. return { success: false, error: 'You do not have permission to create pages' }
  1260. end
  1261. page = current_api_user.pages.build(
  1262. title: arguments['title'],
  1263. content: arguments['content'],
  1264. excerpt: arguments['excerpt'],
  1265. status: arguments['status'] || 'draft',
  1266. slug: arguments['slug'],
  1267. parent_id: arguments['parent_id'],
  1268. meta_title: arguments['meta_title'],
  1269. meta_description: arguments['meta_description']
  1270. )
  1271. if arguments['published_at'].present?
  1272. page.published_at = Time.parse(arguments['published_at'])
  1273. end
  1274. if page.save
  1275. # Handle meta fields
  1276. if arguments['meta_fields'].present?
  1277. arguments['meta_fields'].each do |key, value|
  1278. page.set_meta(key, value)
  1279. end
  1280. end
  1281. {
  1282. success: true,
  1283. data: {
  1284. page: serialize_page(page)
  1285. }
  1286. }
  1287. else
  1288. {
  1289. success: false,
  1290. error: page.errors.full_messages.join(', ')
  1291. }
  1292. end
  1293. end
  1294. def execute_update_page(arguments)
  1295. page = Page.find(arguments['id'])
  1296. unless current_api_user&.can_edit_others_posts? || (current_api_user&.id == page.user_id)
  1297. return { success: false, error: 'You do not have permission to edit this page' }
  1298. end
  1299. update_params = arguments.except('id', 'meta_fields')
  1300. if arguments['published_at'].present?
  1301. update_params['published_at'] = Time.parse(arguments['published_at'])
  1302. end
  1303. if page.update(update_params)
  1304. # Handle meta fields
  1305. if arguments['meta_fields'].present?
  1306. arguments['meta_fields'].each do |key, value|
  1307. page.set_meta(key, value)
  1308. end
  1309. end
  1310. {
  1311. success: true,
  1312. data: {
  1313. page: serialize_page(page)
  1314. }
  1315. }
  1316. else
  1317. {
  1318. success: false,
  1319. error: page.errors.full_messages.join(', ')
  1320. }
  1321. end
  1322. end
  1323. def execute_delete_page(arguments)
  1324. page = Page.find(arguments['id'])
  1325. unless current_api_user&.can_delete_posts? || (current_api_user&.id == page.user_id)
  1326. return { success: false, error: 'You do not have permission to delete this page' }
  1327. end
  1328. page.discard
  1329. {
  1330. success: true,
  1331. data: {
  1332. success: true,
  1333. message: 'Page moved to trash'
  1334. }
  1335. }
  1336. end
  1337. def execute_get_taxonomies(arguments)
  1338. taxonomies = Taxonomy.all
  1339. taxonomies = taxonomies.where(hierarchical: arguments['hierarchical']) if arguments['hierarchical'].present?
  1340. if arguments['object_types'].present?
  1341. object_types_filter = arguments['object_types'].map { |type| "%#{type}%" }
  1342. taxonomies = taxonomies.where(object_types.map { |type| "object_types LIKE ?" }.join(' OR '), *object_types_filter)
  1343. end
  1344. {
  1345. success: true,
  1346. data: {
  1347. taxonomies: taxonomies.map { |taxonomy| serialize_taxonomy(taxonomy) }
  1348. }
  1349. }
  1350. end
  1351. def execute_get_terms(arguments)
  1352. taxonomy = Taxonomy.find_by(slug: arguments['taxonomy'])
  1353. unless taxonomy
  1354. return { success: false, error: 'Taxonomy not found' }
  1355. end
  1356. terms = taxonomy.terms
  1357. # Apply filters
  1358. terms = terms.where(parent_id: arguments['parent_id']) if arguments['parent_id'].present?
  1359. terms = terms.root_terms if arguments['root_only'] == true
  1360. terms = terms.where('name LIKE ?', "%#{arguments['search']}%") if arguments['search'].present?
  1361. # Pagination
  1362. limit = arguments['limit'] || 50
  1363. offset = arguments['offset'] || 0
  1364. total = terms.count
  1365. terms = terms.limit(limit).offset(offset).ordered
  1366. {
  1367. success: true,
  1368. data: {
  1369. terms: terms.map { |term| serialize_term(term) },
  1370. total: total,
  1371. limit: limit,
  1372. offset: offset
  1373. }
  1374. }
  1375. end
  1376. def execute_create_term(arguments)
  1377. unless current_api_user&.can_edit_others_posts?
  1378. return { success: false, error: 'You do not have permission to create terms' }
  1379. end
  1380. taxonomy = Taxonomy.find_by(slug: arguments['taxonomy'])
  1381. unless taxonomy
  1382. return { success: false, error: 'Taxonomy not found' }
  1383. end
  1384. term = taxonomy.terms.build(
  1385. name: arguments['name'],
  1386. description: arguments['description'],
  1387. parent_id: arguments['parent_id'],
  1388. slug: arguments['slug'],
  1389. metadata: arguments['metadata'] || {}
  1390. )
  1391. if term.save
  1392. {
  1393. success: true,
  1394. data: {
  1395. term: serialize_term(term)
  1396. }
  1397. }
  1398. else
  1399. {
  1400. success: false,
  1401. error: term.errors.full_messages.join(', ')
  1402. }
  1403. end
  1404. end
  1405. def execute_update_term(arguments)
  1406. unless current_api_user&.can_edit_others_posts?
  1407. return { success: false, error: 'You do not have permission to edit terms' }
  1408. end
  1409. term = Term.find(arguments['id'])
  1410. update_params = arguments.except('id')
  1411. if term.update(update_params)
  1412. {
  1413. success: true,
  1414. data: {
  1415. term: serialize_term(term)
  1416. }
  1417. }
  1418. else
  1419. {
  1420. success: false,
  1421. error: term.errors.full_messages.join(', ')
  1422. }
  1423. end
  1424. end
  1425. def execute_delete_term(arguments)
  1426. unless current_api_user&.administrator?
  1427. return { success: false, error: 'You do not have permission to delete terms' }
  1428. end
  1429. term = Term.find(arguments['id'])
  1430. term.destroy
  1431. {
  1432. success: true,
  1433. data: {
  1434. success: true,
  1435. message: 'Term deleted'
  1436. }
  1437. }
  1438. end
  1439. def execute_get_content_types(arguments)
  1440. content_types = ContentType.all
  1441. {
  1442. success: true,
  1443. data: {
  1444. content_types: content_types.map { |ct| serialize_content_type(ct) }
  1445. }
  1446. }
  1447. end
  1448. def execute_get_media(arguments)
  1449. media = Medium.all
  1450. # Apply filters
  1451. media = media.where('filename LIKE ? OR title LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
  1452. media = media.where(mime_type: arguments['mime_type']) if arguments['mime_type'].present?
  1453. media = media.where(uploaded_by_id: arguments['uploaded_by']) if arguments['uploaded_by'].present?
  1454. if arguments['date_from'].present?
  1455. media = media.where('created_at >= ?', Date.parse(arguments['date_from']))
  1456. end
  1457. if arguments['date_to'].present?
  1458. media = media.where('created_at <= ?', Date.parse(arguments['date_to']))
  1459. end
  1460. # Pagination
  1461. limit = arguments['limit'] || 20
  1462. offset = arguments['offset'] || 0
  1463. total = media.count
  1464. media = media.limit(limit).offset(offset).order(created_at: :desc)
  1465. {
  1466. success: true,
  1467. data: {
  1468. media: media.map { |m| serialize_media(m) },
  1469. total: total,
  1470. limit: limit,
  1471. offset: offset
  1472. }
  1473. }
  1474. end
  1475. def execute_get_media_streaming(arguments, &block)
  1476. yield(0.1, { message: "Fetching media..." })
  1477. media = Medium.all
  1478. yield(0.3, { message: "Applying filters..." })
  1479. # Apply same filters as execute_get_media
  1480. media = media.where('filename LIKE ? OR title LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
  1481. media = media.where(mime_type: arguments['mime_type']) if arguments['mime_type'].present?
  1482. media = media.where(uploaded_by_id: arguments['uploaded_by']) if arguments['uploaded_by'].present?
  1483. yield(0.6, { message: "Counting total..." })
  1484. total = media.count
  1485. yield(0.8, { message: "Serializing data..." })
  1486. limit = arguments['limit'] || 20
  1487. offset = arguments['offset'] || 0
  1488. media = media.limit(limit).offset(offset).order(created_at: :desc)
  1489. {
  1490. success: true,
  1491. data: {
  1492. media: media.map { |m| serialize_media(m) },
  1493. total: total,
  1494. limit: limit,
  1495. offset: offset
  1496. }
  1497. }
  1498. end
  1499. def execute_upload_media(arguments)
  1500. unless current_api_user&.can_upload_files?
  1501. return { success: false, error: 'You do not have permission to upload media' }
  1502. end
  1503. # Decode base64 file
  1504. file_data = Base64.decode64(arguments['file'])
  1505. filename = arguments['filename']
  1506. # Create temporary file
  1507. temp_file = Tempfile.new([filename, File.extname(filename)])
  1508. temp_file.binmode
  1509. temp_file.write(file_data)
  1510. temp_file.rewind
  1511. # Create media record
  1512. media = Medium.new(
  1513. filename: filename,
  1514. title: arguments['title'] || filename,
  1515. alt_text: arguments['alt_text'],
  1516. caption: arguments['caption'],
  1517. description: arguments['description'],
  1518. uploaded_by: current_api_user
  1519. )
  1520. # Attach file
  1521. media.file.attach(
  1522. io: temp_file,
  1523. filename: filename,
  1524. content_type: MIME::Types.type_for(filename).first&.content_type || 'application/octet-stream'
  1525. )
  1526. if media.save
  1527. temp_file.close
  1528. temp_file.unlink
  1529. {
  1530. success: true,
  1531. data: {
  1532. media: serialize_media(media)
  1533. }
  1534. }
  1535. else
  1536. temp_file.close
  1537. temp_file.unlink
  1538. {
  1539. success: false,
  1540. error: media.errors.full_messages.join(', ')
  1541. }
  1542. end
  1543. end
  1544. def execute_get_users(arguments)
  1545. users = User.all
  1546. # Apply filters
  1547. users = users.where('name LIKE ? OR email LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
  1548. users = users.where(role: arguments['role']) if arguments['role'].present?
  1549. users = users.where(active: arguments['status'] == 'active') if arguments['status'].present?
  1550. # Pagination
  1551. limit = arguments['limit'] || 20
  1552. offset = arguments['offset'] || 0
  1553. total = users.count
  1554. users = users.limit(limit).offset(offset).order(created_at: :desc)
  1555. {
  1556. success: true,
  1557. data: {
  1558. users: users.map { |user| serialize_user(user) },
  1559. total: total,
  1560. limit: limit,
  1561. offset: offset
  1562. }
  1563. }
  1564. end
  1565. def execute_get_system_info(arguments)
  1566. {
  1567. success: true,
  1568. data: {
  1569. system: {
  1570. name: 'RailsPress API',
  1571. version: 'v1',
  1572. rails_version: Rails.version,
  1573. ruby_version: RUBY_VERSION,
  1574. environment: Rails.env,
  1575. statistics: {
  1576. posts_count: Post.count,
  1577. pages_count: Page.count,
  1578. users_count: User.count,
  1579. media_count: Medium.count,
  1580. comments_count: Comment.count
  1581. }
  1582. }
  1583. }
  1584. }
  1585. end
  1586. # Serialization methods
  1587. def serialize_post(post, detailed: false)
  1588. data = {
  1589. id: post.id,
  1590. title: post.title,
  1591. slug: post.slug,
  1592. content: post.content.to_s,
  1593. excerpt: post.excerpt,
  1594. status: post.status,
  1595. published_at: post.published_at&.iso8601,
  1596. created_at: post.created_at.iso8601,
  1597. updated_at: post.updated_at.iso8601,
  1598. author: {
  1599. id: post.user.id,
  1600. name: post.user.name,
  1601. email: post.user.email
  1602. },
  1603. categories: post.categories.map { |cat| { id: cat.id, name: cat.name, slug: cat.slug } },
  1604. tags: post.tags.map { |tag| { id: tag.id, name: tag.name, slug: tag.slug } },
  1605. meta_fields: post.meta_fields.map { |mf| { key: mf.key, value: mf.value } }.index_by { |mf| mf[:key] }
  1606. }
  1607. if detailed
  1608. data[:comments] = post.comments.map { |comment| serialize_comment(comment) }
  1609. end
  1610. data
  1611. end
  1612. def serialize_page(page, detailed: false)
  1613. data = {
  1614. id: page.id,
  1615. title: page.title,
  1616. slug: page.slug,
  1617. content: page.content.to_s,
  1618. excerpt: page.excerpt,
  1619. status: page.status,
  1620. published_at: page.published_at&.iso8601,
  1621. created_at: page.created_at.iso8601,
  1622. updated_at: page.updated_at.iso8601,
  1623. author: {
  1624. id: page.user.id,
  1625. name: page.user.name,
  1626. email: page.user.email
  1627. },
  1628. parent: page.parent ? { id: page.parent.id, title: page.parent.title, slug: page.parent.slug } : nil,
  1629. children: page.children.map { |child| { id: child.id, title: child.title, slug: child.slug } },
  1630. meta_fields: page.meta_fields.map { |mf| { key: mf.key, value: mf.value } }.index_by { |mf| mf[:key] }
  1631. }
  1632. if detailed
  1633. data[:comments] = page.comments.map { |comment| serialize_comment(comment) }
  1634. end
  1635. data
  1636. end
  1637. def serialize_taxonomy(taxonomy)
  1638. {
  1639. id: taxonomy.id,
  1640. name: taxonomy.name,
  1641. slug: taxonomy.slug,
  1642. description: taxonomy.description,
  1643. hierarchical: taxonomy.hierarchical,
  1644. object_types: taxonomy.object_types,
  1645. term_count: taxonomy.term_count,
  1646. settings: taxonomy.settings
  1647. }
  1648. end
  1649. def serialize_term(term)
  1650. {
  1651. id: term.id,
  1652. name: term.name,
  1653. slug: term.slug,
  1654. description: term.description,
  1655. count: term.count,
  1656. parent_id: term.parent_id,
  1657. taxonomy: {
  1658. id: term.taxonomy.id,
  1659. name: term.taxonomy.name,
  1660. slug: term.taxonomy.slug
  1661. },
  1662. children: term.children.map { |child| { id: child.id, name: child.name, slug: child.slug } }
  1663. }
  1664. end
  1665. def serialize_content_type(content_type)
  1666. {
  1667. id: content_type.id,
  1668. name: content_type.name,
  1669. slug: content_type.slug,
  1670. description: content_type.description,
  1671. icon: content_type.icon,
  1672. supports: content_type.supports,
  1673. labels: content_type.labels,
  1674. capabilities: content_type.capabilities,
  1675. settings: content_type.settings
  1676. }
  1677. end
  1678. def serialize_media(media)
  1679. {
  1680. id: media.id,
  1681. filename: media.filename,
  1682. title: media.title,
  1683. alt_text: media.alt_text,
  1684. caption: media.caption,
  1685. description: media.description,
  1686. mime_type: media.mime_type,
  1687. file_size: media.file_size,
  1688. url: media.file.url,
  1689. thumbnail_url: media.file.attached? ? media.file.variant(resize_to_limit: [300, 300]).processed.url : nil,
  1690. uploaded_at: media.created_at.iso8601,
  1691. uploaded_by: {
  1692. id: media.uploaded_by.id,
  1693. name: media.uploaded_by.name,
  1694. email: media.uploaded_by.email
  1695. }
  1696. }
  1697. end
  1698. def serialize_user(user)
  1699. {
  1700. id: user.id,
  1701. name: user.name,
  1702. email: user.email,
  1703. role: user.role,
  1704. status: user.active? ? 'active' : 'inactive',
  1705. created_at: user.created_at.iso8601,
  1706. last_login_at: user.last_sign_in_at&.iso8601
  1707. }
  1708. end
  1709. def serialize_comment(comment)
  1710. {
  1711. id: comment.id,
  1712. content: comment.content,
  1713. author_name: comment.author_name,
  1714. author_email: comment.author_email,
  1715. author_url: comment.author_url,
  1716. status: comment.status,
  1717. created_at: comment.created_at.iso8601,
  1718. updated_at: comment.updated_at.iso8601
  1719. }
  1720. end
  1721. def render_jsonrpc_success(data, id = nil)
  1722. response_data = {
  1723. jsonrpc: '2.0',
  1724. result: data,
  1725. id: id
  1726. }
  1727. render json: response_data
  1728. end
  1729. def render_jsonrpc_error(code, message, id = nil)
  1730. response_data = {
  1731. jsonrpc: '2.0',
  1732. error: {
  1733. code: code,
  1734. message: message
  1735. },
  1736. id: id
  1737. }
  1738. render json: response_data, status: :bad_request
  1739. end
  1740. def authenticate_api_user!
  1741. # Check for API key authentication
  1742. api_key = request.headers['Authorization']&.gsub(/^Bearer /, '') || params[:api_key]
  1743. if api_key.blank?
  1744. render json: {
  1745. success: false,
  1746. error: 'API key required',
  1747. code: 'MISSING_API_KEY'
  1748. }, status: :unauthorized
  1749. return
  1750. end
  1751. @api_user = User.find_by(api_key: api_key)
  1752. unless @api_user
  1753. render json: {
  1754. success: false,
  1755. error: 'Invalid API key',
  1756. code: 'INVALID_API_KEY'
  1757. }, status: :unauthorized
  1758. return
  1759. end
  1760. # Check if user is active
  1761. unless @api_user.active?
  1762. render json: {
  1763. success: false,
  1764. error: 'User account is inactive',
  1765. code: 'INACTIVE_USER'
  1766. }, status: :forbidden
  1767. return
  1768. end
  1769. end
  1770. end
  1771. end
  1772. end

app/controllers/api/v1/media_controller.rb

0.0% lines covered

100.0% branches covered

111 relevant lines. 0 lines covered and 111 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class MediaController < BaseController
  4. before_action :set_medium, only: [:show, :update, :destroy]
  5. # GET /api/v1/media
  6. def index
  7. media = Medium.all
  8. # Filter by type
  9. # media = media.by_type(params[:type]) if params[:type].present? # Temporarily disabled
  10. # Filter by channel
  11. if params[:channel].present?
  12. channel = Channel.find_by(slug: params[:channel])
  13. if channel
  14. # Get media assigned to this channel or global media (no channel assignment)
  15. media = media.left_joins(:channels)
  16. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  17. # Apply channel exclusions
  18. excluded_media_ids = channel.channel_overrides
  19. .exclusions
  20. .enabled
  21. .where(resource_type: 'Medium')
  22. .pluck(:resource_id)
  23. media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
  24. @current_channel = channel
  25. end
  26. end
  27. # Only published for non-authenticated or non-admin users
  28. # unless current_api_user&.can_edit_others_posts?
  29. # media = media.where(status: 'approved')
  30. # end
  31. # Paginate
  32. @media = paginate(media.order(created_at: :desc))
  33. render_success(
  34. @media.map { |medium| medium_serializer(medium) },
  35. { filters: filter_meta }
  36. )
  37. end
  38. # GET /api/v1/media/:id
  39. def show
  40. # Set current channel if channel parameter is provided
  41. if params[:channel].present?
  42. @current_channel = Channel.find_by(slug: params[:channel])
  43. end
  44. render_success(medium_serializer(@medium, detailed: true))
  45. end
  46. # POST /api/v1/media
  47. def create
  48. unless current_api_user&.can_edit_others_posts?
  49. return render_error('You do not have permission to create media', :forbidden)
  50. end
  51. @medium = current_api_user.media.build(medium_params)
  52. if @medium.save
  53. render_success(medium_serializer(@medium), {}, :created)
  54. else
  55. render_error(@medium.errors.full_messages.join(', '))
  56. end
  57. end
  58. # PATCH/PUT /api/v1/media/:id
  59. def update
  60. unless current_api_user&.can_edit_others_posts?
  61. return render_error('You do not have permission to edit media', :forbidden)
  62. end
  63. if @medium.update(medium_params)
  64. render_success(medium_serializer(@medium))
  65. else
  66. render_error(@medium.errors.full_messages.join(', '))
  67. end
  68. end
  69. # DELETE /api/v1/media/:id
  70. def destroy
  71. unless current_api_user&.can_edit_others_posts?
  72. return render_error('You do not have permission to delete media', :forbidden)
  73. end
  74. @medium.destroy
  75. render_success({ message: 'Media deleted successfully' })
  76. end
  77. private
  78. def set_medium
  79. @medium = Medium.find(params[:id])
  80. end
  81. def medium_params
  82. params.require(:medium).permit(
  83. :title, :description, :file, :alt_text, :status
  84. )
  85. end
  86. def medium_serializer(medium, detailed: false)
  87. # Get channel slugs for this medium
  88. channel_slugs = medium.channels.pluck(:slug)
  89. # Start with basic medium data
  90. medium_data = {
  91. id: medium.id,
  92. title: medium.title,
  93. file_name: medium.file_name,
  94. file_type: medium.file_type,
  95. channels: channel_slugs,
  96. channel_context: @current_channel&.slug
  97. }
  98. # Add detailed fields if requested
  99. if detailed
  100. medium_data.merge!({
  101. description: medium.description,
  102. alt_text: medium.alt_text,
  103. file_size: medium.file_size,
  104. created_at: medium.created_at,
  105. updated_at: medium.updated_at,
  106. url: medium.file_url if medium.respond_to?(:file_url)
  107. })
  108. end
  109. # Apply channel overrides if current channel is set
  110. if @current_channel
  111. original_data = medium_data.dup
  112. overridden_data, provenance = @current_channel.apply_overrides_to_data(
  113. original_data,
  114. 'Medium',
  115. medium.id,
  116. true
  117. )
  118. # Merge overridden data
  119. medium_data.merge!(overridden_data)
  120. # Add provenance information
  121. medium_data[:provenance] = provenance if provenance.present?
  122. end
  123. medium_data
  124. end
  125. def filter_meta
  126. {
  127. type: params[:type],
  128. channel: params[:channel]
  129. }
  130. end
  131. end
  132. end
  133. end

app/controllers/api/v1/media_controller_new.rb

0.0% lines covered

100.0% branches covered

81 relevant lines. 0 lines covered and 81 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class MediaController < BaseController
  4. before_action :set_medium, only: [:show, :update, :destroy]
  5. # GET /api/v1/media
  6. def index
  7. media = Medium.all
  8. # Filter by type
  9. media = media.by_type(params[:type]) if params[:type].present?
  10. # Filter by channel
  11. if params[:channel].present?
  12. channel = Channel.find_by(slug: params[:channel])
  13. if channel
  14. # Get media assigned to this channel or global media (no channel assignment)
  15. # media = media.left_joins(:channels)
  16. # .where('channels.id = ? OR channels.id IS NULL', channel.id)
  17. # Apply channel exclusions
  18. # excluded_media_ids = channel.channel_overrides
  19. # .exclusions
  20. # .enabled
  21. # .where(resource_type: 'Medium')
  22. # .pluck(:resource_id)
  23. # media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
  24. @current_channel = channel
  25. end
  26. end
  27. # Only published for non-authenticated or non-admin users
  28. unless current_api_user&.can_edit_others_posts?
  29. media = media.where(status: 'approved')
  30. end
  31. # Paginate
  32. @media = paginate(media.order(created_at: :desc))
  33. render_success(
  34. @media.map { |medium| medium_serializer(medium) },
  35. { filters: filter_meta }
  36. )
  37. end
  38. # GET /api/v1/media/:id
  39. def show
  40. render_success(medium_serializer(@medium, detailed: true))
  41. end
  42. # POST /api/v1/media
  43. def create
  44. unless current_api_user&.can_edit_others_posts?
  45. return render_error('You do not have permission to create media', :forbidden)
  46. end
  47. @medium = current_api_user.media.build(medium_params)
  48. if @medium.save
  49. render_success(medium_serializer(@medium), {}, :created)
  50. else
  51. render_error(@medium.errors.full_messages.join(', '))
  52. end
  53. end
  54. # PATCH/PUT /api/v1/media/:id
  55. def update
  56. unless current_api_user&.can_edit_others_posts?
  57. return render_error('You do not have permission to edit media', :forbidden)
  58. end
  59. if @medium.update(medium_params)
  60. render_success(medium_serializer(@medium))
  61. else
  62. render_error(@medium.errors.full_messages.join(', '))
  63. end
  64. end
  65. # DELETE /api/v1/media/:id
  66. def destroy
  67. unless current_api_user&.can_edit_others_posts?
  68. return render_error('You do not have permission to delete media', :forbidden)
  69. end
  70. @medium.destroy
  71. render_success({ message: 'Media deleted successfully' })
  72. end
  73. private
  74. def set_medium
  75. @medium = Medium.find(params[:id])
  76. end
  77. def medium_params
  78. params.require(:medium).permit(
  79. :title, :description, :file, :alt_text, :status
  80. )
  81. end
  82. def medium_serializer(medium, detailed: false)
  83. {
  84. id: medium.id,
  85. title: medium.title,
  86. description: medium.description,
  87. status: medium.status,
  88. created_at: medium.created_at,
  89. updated_at: medium.updated_at
  90. }
  91. end
  92. def filter_meta
  93. {
  94. type: params[:type],
  95. channel: params[:channel]
  96. }
  97. end
  98. end
  99. end
  100. end

app/controllers/api/v1/media_controller_old.rb

0.0% lines covered

100.0% branches covered

188 relevant lines. 0 lines covered and 188 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::MediaController < ApplicationController
  2. before_action :authenticate_user!
  3. before_action :set_medium, only: %i[show update destroy approve reject]
  4. before_action :validate_media_permissions
  5. # GET /api/v1/media
  6. def index
  7. @media = Medium.all
  8. # Filter by type
  9. if params[:type].present?
  10. @media = @media.by_type(params[:type])
  11. end
  12. # Filter by user
  13. if params[:user_id].present?
  14. @media = @media.where(user_id: params[:user_id])
  15. end
  16. # Filter by quarantine status
  17. if params[:quarantined].present?
  18. @media = params[:quarantined] == 'true' ? @media.quarantined : @media.approved
  19. end
  20. # Filter by channel
  21. if params[:channel].present?
  22. channel = Channel.find_by(slug: params[:channel])
  23. if channel
  24. # Get media assigned to this channel or global media (no channel assignment)
  25. @media = @media.left_joins(:channels)
  26. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  27. # Apply channel exclusions
  28. excluded_media_ids = channel.channel_overrides
  29. .exclusions
  30. .enabled
  31. .where(resource_type: 'Medium')
  32. .pluck(:resource_id)
  33. @media = @media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
  34. @current_channel = channel
  35. end
  36. end
  37. # Search
  38. if params[:search].present?
  39. @media = @media.where("media.title ILIKE ? OR media.description ILIKE ?",
  40. "%#{params[:search]}%", "%#{params[:search]}%")
  41. end
  42. # Pagination
  43. @media = @media.page(params[:page]).per(params[:per_page] || 20)
  44. render json: {
  45. media: @media.map { |medium| medium_serializer(medium) },
  46. pagination: {
  47. current_page: @media.current_page,
  48. total_pages: @media.total_pages,
  49. total_count: @media.total_count,
  50. per_page: @media.limit_value
  51. },
  52. stats: {
  53. total: Medium.count,
  54. images: Medium.images.count,
  55. videos: Medium.videos.count,
  56. documents: Medium.documents.count,
  57. quarantined: Medium.quarantined.count
  58. },
  59. filters: {
  60. type: params[:type],
  61. user_id: params[:user_id],
  62. quarantined: params[:quarantined],
  63. search: params[:search],
  64. channel: params[:channel]
  65. }
  66. }
  67. end
  68. # GET /api/v1/media/:id
  69. def show
  70. render json: @medium.api_attributes
  71. end
  72. # POST /api/v1/media
  73. def create
  74. # Check if we're creating from an existing upload
  75. if params[:upload_id].present?
  76. upload = current_user.uploads.find(params[:upload_id])
  77. @medium = Medium.new(medium_params.except(:file))
  78. @medium.user = current_user
  79. @medium.upload = upload
  80. if @medium.save
  81. render json: @medium.api_attributes, status: :created
  82. else
  83. render json: {
  84. error: 'Media creation failed',
  85. details: @medium.errors.full_messages
  86. }, status: :unprocessable_entity
  87. end
  88. else
  89. # Create new upload and media together
  90. if params[:medium][:file].present?
  91. # Create upload first
  92. upload = current_user.uploads.build(
  93. title: params[:medium][:title] || params[:medium][:file].original_filename,
  94. description: params[:medium][:description],
  95. alt_text: params[:medium][:alt_text]
  96. )
  97. upload.file.attach(params[:medium][:file])
  98. upload.storage_provider = StorageProvider.active.first
  99. # Security validation
  100. security = UploadSecurity.current
  101. unless security.file_allowed?(params[:medium][:file])
  102. render json: {
  103. error: 'File not allowed',
  104. details: 'File type, size, or extension is not permitted'
  105. }, status: :forbidden
  106. return
  107. end
  108. # Check for suspicious files
  109. if security.file_suspicious?(params[:medium][:file])
  110. if security.quarantine_suspicious?
  111. upload.quarantined = true
  112. upload.quarantine_reason = 'Suspicious file pattern detected'
  113. else
  114. render json: {
  115. error: 'File rejected',
  116. details: 'File appears to be suspicious and has been blocked'
  117. }, status: :forbidden
  118. return
  119. end
  120. end
  121. if upload.save
  122. # Create media record
  123. @medium = Medium.new(medium_params.except(:file))
  124. @medium.user = current_user
  125. @medium.upload = upload
  126. if @medium.save
  127. render json: @medium.api_attributes, status: :created
  128. else
  129. upload.destroy # Clean up upload if media creation fails
  130. render json: {
  131. error: 'Media creation failed',
  132. details: @medium.errors.full_messages
  133. }, status: :unprocessable_entity
  134. end
  135. else
  136. render json: {
  137. error: 'Upload failed',
  138. details: upload.errors.full_messages
  139. }, status: :unprocessable_entity
  140. end
  141. else
  142. render json: {
  143. error: 'No file provided',
  144. details: 'Either file or upload_id must be provided'
  145. }, status: :bad_request
  146. end
  147. end
  148. end
  149. # PATCH/PUT /api/v1/media/:id
  150. def update
  151. if @medium.update(medium_params.except(:file))
  152. render json: @medium.api_attributes
  153. else
  154. render json: {
  155. error: 'Update failed',
  156. details: @medium.errors.full_messages
  157. }, status: :unprocessable_entity
  158. end
  159. end
  160. # DELETE /api/v1/media/:id
  161. def destroy
  162. @medium.destroy!
  163. head :no_content
  164. end
  165. # POST /api/v1/media/:id/approve
  166. def approve
  167. if @medium.quarantined?
  168. @medium.upload.approve!
  169. render json: { message: 'Media approved and released from quarantine' }
  170. else
  171. render json: { error: 'Media is not quarantined' }, status: :bad_request
  172. end
  173. end
  174. # POST /api/v1/media/:id/reject
  175. def reject
  176. if @medium.quarantined?
  177. @medium.destroy!
  178. render json: { message: 'Media rejected and deleted' }
  179. else
  180. render json: { error: 'Media is not quarantined' }, status: :bad_request
  181. end
  182. end
  183. private
  184. def medium_serializer(medium)
  185. data = medium.api_attributes.merge({
  186. channels: medium.channels.map { |c| c.slug },
  187. channel_context: @current_channel&.slug
  188. })
  189. # Apply channel overrides if current channel is set
  190. if @current_channel
  191. data = @current_channel.apply_overrides_to_data(data, 'Medium', medium.id)
  192. # Add provenance information
  193. data[:provenance] = {
  194. title: data[:title] != medium.title ? 'channel_override' : 'resource',
  195. description: data[:description] != medium.description ? 'channel_override' : 'resource'
  196. }
  197. end
  198. data
  199. end
  200. def set_medium
  201. @medium = current_user.media.find(params[:id])
  202. end
  203. def validate_media_permissions
  204. unless current_user.can_upload_media?
  205. render json: { error: 'Insufficient permissions' }, status: :forbidden
  206. end
  207. end
  208. def medium_params
  209. params.require(:medium).permit(:title, :description, :alt_text, :file, :upload_id)
  210. end
  211. end

app/controllers/api/v1/menus_controller.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class MenusController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:index, :show]
  5. before_action :set_menu, only: [:show, :update, :destroy]
  6. # GET /api/v1/menus
  7. def index
  8. menus = Menu.includes(:menu_items)
  9. # Filter by location
  10. menus = menus.by_location(params[:location]) if params[:location].present?
  11. @menus = paginate(menus)
  12. render_success(
  13. @menus.map { |menu| menu_serializer(menu) }
  14. )
  15. end
  16. # GET /api/v1/menus/:id
  17. def show
  18. render_success(menu_serializer(@menu, detailed: true))
  19. end
  20. # POST /api/v1/menus
  21. def create
  22. unless current_api_user.can_edit_others_posts?
  23. return render_error('You do not have permission to create menus', :forbidden)
  24. end
  25. @menu = Menu.new(menu_params)
  26. if @menu.save
  27. render_success(menu_serializer(@menu), {}, :created)
  28. else
  29. render_error(@menu.errors.full_messages.join(', '))
  30. end
  31. end
  32. # PATCH/PUT /api/v1/menus/:id
  33. def update
  34. unless current_api_user.can_edit_others_posts?
  35. return render_error('You do not have permission to edit menus', :forbidden)
  36. end
  37. if @menu.update(menu_params)
  38. render_success(menu_serializer(@menu))
  39. else
  40. render_error(@menu.errors.full_messages.join(', '))
  41. end
  42. end
  43. # DELETE /api/v1/menus/:id
  44. def destroy
  45. unless current_api_user.administrator?
  46. return render_error('Only administrators can delete menus', :forbidden)
  47. end
  48. @menu.destroy
  49. render_success({ message: 'Menu deleted successfully' })
  50. end
  51. private
  52. def set_menu
  53. @menu = Menu.find(params[:id])
  54. end
  55. def menu_params
  56. params.require(:menu).permit(:name, :location)
  57. end
  58. def menu_serializer(menu, detailed: false)
  59. data = {
  60. id: menu.id,
  61. name: menu.name,
  62. location: menu.location,
  63. items_count: menu.menu_items.count
  64. }
  65. if detailed
  66. data[:items] = serialize_menu_items(menu.root_items)
  67. end
  68. data
  69. end
  70. def serialize_menu_items(items)
  71. items.map do |item|
  72. {
  73. id: item.id,
  74. label: item.label,
  75. url: item.url,
  76. target: item.target,
  77. css_class: item.css_class,
  78. position: item.position,
  79. children: serialize_menu_items(item.children.ordered)
  80. }
  81. end
  82. end
  83. end
  84. end
  85. end

app/controllers/api/v1/meta_fields_controller.rb

0.0% lines covered

100.0% branches covered

206 relevant lines. 0 lines covered and 206 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::MetaFieldsController < Api::V1::BaseController
  2. before_action :authenticate_api_key
  3. before_action :set_metable
  4. before_action :set_meta_field, only: [:show, :update, :destroy]
  5. # GET /api/v1/:metable_type/:metable_id/meta_fields
  6. def index
  7. meta_fields = @metable.meta_fields
  8. meta_fields = meta_fields.by_key(params[:key]) if params[:key].present?
  9. meta_fields = meta_fields.immutable if params[:immutable] == 'true'
  10. meta_fields = meta_fields.mutable if params[:immutable] == 'false'
  11. render json: {
  12. meta_fields: meta_fields.map do |mf|
  13. {
  14. id: mf.id,
  15. key: mf.key,
  16. value: mf.value,
  17. immutable: mf.immutable,
  18. created_at: mf.created_at,
  19. updated_at: mf.updated_at
  20. }
  21. end
  22. }
  23. end
  24. # GET /api/v1/:metable_type/:metable_id/meta_fields/:key
  25. def show
  26. render json: {
  27. meta_field: {
  28. id: @meta_field.id,
  29. key: @meta_field.key,
  30. value: @meta_field.value,
  31. immutable: @meta_field.immutable,
  32. created_at: @meta_field.created_at,
  33. updated_at: @meta_field.updated_at
  34. }
  35. }
  36. end
  37. # POST /api/v1/:metable_type/:metable_id/meta_fields
  38. def create
  39. meta_field = @metable.meta_fields.build(meta_field_params)
  40. if meta_field.save
  41. render json: {
  42. meta_field: {
  43. id: meta_field.id,
  44. key: meta_field.key,
  45. value: meta_field.value,
  46. immutable: meta_field.immutable,
  47. created_at: meta_field.created_at,
  48. updated_at: meta_field.updated_at
  49. }
  50. }, status: :created
  51. else
  52. render json: {
  53. errors: meta_field.errors.full_messages
  54. }, status: :unprocessable_entity
  55. end
  56. end
  57. # PATCH/PUT /api/v1/:metable_type/:metable_id/meta_fields/:key
  58. def update
  59. if @meta_field.update(meta_field_params)
  60. render json: {
  61. meta_field: {
  62. id: @meta_field.id,
  63. key: @meta_field.key,
  64. value: @meta_field.value,
  65. immutable: @meta_field.immutable,
  66. created_at: @meta_field.created_at,
  67. updated_at: @meta_field.updated_at
  68. }
  69. }
  70. else
  71. render json: {
  72. errors: @meta_field.errors.full_messages
  73. }, status: :unprocessable_entity
  74. end
  75. end
  76. # DELETE /api/v1/:metable_type/:metable_id/meta_fields/:key
  77. def destroy
  78. if @meta_field.destroy
  79. head :no_content
  80. else
  81. render json: {
  82. errors: @meta_field.errors.full_messages
  83. }, status: :unprocessable_entity
  84. end
  85. end
  86. # POST /api/v1/:metable_type/:metable_id/meta_fields/bulk
  87. def bulk_create
  88. meta_fields_data = params[:meta_fields] || []
  89. created_meta_fields = []
  90. errors = []
  91. @metable.transaction do
  92. meta_fields_data.each do |meta_field_data|
  93. meta_field = @metable.meta_fields.build(meta_field_data.permit(:key, :value, :immutable))
  94. if meta_field.save
  95. created_meta_fields << {
  96. id: meta_field.id,
  97. key: meta_field.key,
  98. value: meta_field.value,
  99. immutable: meta_field.immutable,
  100. created_at: meta_field.created_at,
  101. updated_at: meta_field.updated_at
  102. }
  103. else
  104. errors << {
  105. key: meta_field_data[:key],
  106. errors: meta_field.errors.full_messages
  107. }
  108. end
  109. end
  110. if errors.any?
  111. raise ActiveRecord::Rollback
  112. end
  113. end
  114. if errors.any?
  115. render json: { errors: errors }, status: :unprocessable_entity
  116. else
  117. render json: { meta_fields: created_meta_fields }, status: :created
  118. end
  119. end
  120. # PATCH /api/v1/:metable_type/:metable_id/meta_fields/bulk
  121. def bulk_update
  122. meta_fields_data = params[:meta_fields] || {}
  123. updated_meta_fields = []
  124. errors = []
  125. @metable.transaction do
  126. meta_fields_data.each do |key, data|
  127. meta_field = @metable.meta_fields.find_by(key: key)
  128. if meta_field
  129. if meta_field.update(data.permit(:value, :immutable))
  130. updated_meta_fields << {
  131. id: meta_field.id,
  132. key: meta_field.key,
  133. value: meta_field.value,
  134. immutable: meta_field.immutable,
  135. created_at: meta_field.created_at,
  136. updated_at: meta_field.updated_at
  137. }
  138. else
  139. errors << {
  140. key: key,
  141. errors: meta_field.errors.full_messages
  142. }
  143. end
  144. else
  145. errors << {
  146. key: key,
  147. errors: ["Meta field not found"]
  148. }
  149. end
  150. end
  151. if errors.any?
  152. raise ActiveRecord::Rollback
  153. end
  154. end
  155. if errors.any?
  156. render json: { errors: errors }, status: :unprocessable_entity
  157. else
  158. render json: { meta_fields: updated_meta_fields }
  159. end
  160. end
  161. private
  162. def authenticate_api_key
  163. api_key = request.headers['Authorization']&.split(' ')&.last
  164. @api_user = User.find_by(api_key: api_key)
  165. unless @api_user
  166. render json: {
  167. error: {
  168. message: "Invalid API key",
  169. type: "authentication_error",
  170. code: "invalid_api_key"
  171. }
  172. }, status: :unauthorized
  173. return false
  174. end
  175. end
  176. def set_metable
  177. metable_type = params[:metable_type].classify
  178. metable_id = params[:metable_id]
  179. # Validate metable_type
  180. unless %w[Post Page User AiAgent].include?(metable_type)
  181. render json: {
  182. error: {
  183. message: "Invalid metable type. Must be one of: Post, Page, User, AiAgent",
  184. type: "invalid_request_error",
  185. code: "invalid_metable_type"
  186. }
  187. }, status: :bad_request
  188. return
  189. end
  190. @metable = metable_type.constantize.find(metable_id)
  191. rescue ActiveRecord::RecordNotFound
  192. render json: {
  193. error: {
  194. message: "#{metable_type} not found",
  195. type: "not_found_error",
  196. code: "metable_not_found"
  197. }
  198. }, status: :not_found
  199. end
  200. def set_meta_field
  201. @meta_field = @metable.meta_fields.find_by!(key: params[:key])
  202. rescue ActiveRecord::RecordNotFound
  203. render json: {
  204. error: {
  205. message: "Meta field not found",
  206. type: "not_found_error",
  207. code: "meta_field_not_found"
  208. }
  209. }, status: :not_found
  210. end
  211. def meta_field_params
  212. params.require(:meta_field).permit(:key, :value, :immutable)
  213. end
  214. end

app/controllers/api/v1/openai_controller.rb

0.0% lines covered

100.0% branches covered

219 relevant lines. 0 lines covered and 219 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::OpenaiController < ApplicationController
  2. skip_before_action :verify_authenticity_token
  3. before_action :authenticate_api_key
  4. before_action :set_agent, only: [:chat_completions]
  5. before_action :validate_request, only: [:chat_completions]
  6. # POST /v1/chat/completions
  7. def chat_completions
  8. start_time = Time.current
  9. begin
  10. # Execute the agent
  11. result = @agent.execute(user_message, build_context, @api_user)
  12. # Calculate tokens (rough estimation)
  13. prompt_tokens = calculate_tokens(full_prompt)
  14. completion_tokens = calculate_tokens(result.to_s)
  15. total_tokens = prompt_tokens + completion_tokens
  16. response_time = Time.current - start_time
  17. # Create usage log
  18. @agent.ai_usages.create!(
  19. user: @api_user,
  20. prompt: full_prompt,
  21. response: result.to_s,
  22. tokens_used: total_tokens,
  23. cost: calculate_cost(prompt_tokens, completion_tokens),
  24. response_time: response_time,
  25. success: true,
  26. metadata: {
  27. api_request: true,
  28. model: params[:model],
  29. messages: params[:messages],
  30. temperature: params[:temperature],
  31. max_tokens: params[:max_tokens]
  32. }
  33. )
  34. render json: {
  35. id: generate_chat_id,
  36. object: "chat.completion",
  37. created: Time.current.to_i,
  38. model: params[:model],
  39. choices: [
  40. {
  41. index: 0,
  42. message: {
  43. role: "assistant",
  44. content: result.to_s
  45. },
  46. finish_reason: "stop"
  47. }
  48. ],
  49. usage: {
  50. prompt_tokens: prompt_tokens,
  51. completion_tokens: completion_tokens,
  52. total_tokens: total_tokens
  53. }
  54. }
  55. rescue => e
  56. response_time = Time.current - start_time
  57. # Log failed usage
  58. @agent.ai_usages.create!(
  59. user: @api_user,
  60. prompt: full_prompt,
  61. response: nil,
  62. tokens_used: calculate_tokens(full_prompt),
  63. cost: 0.0,
  64. response_time: response_time,
  65. success: false,
  66. error_message: e.message,
  67. metadata: {
  68. api_request: true,
  69. model: params[:model],
  70. messages: params[:messages],
  71. error_class: e.class.name
  72. }
  73. )
  74. render json: {
  75. error: {
  76. message: e.message,
  77. type: "server_error",
  78. code: "internal_error"
  79. }
  80. }, status: :internal_server_error
  81. end
  82. end
  83. # GET /v1/models
  84. def models
  85. models_data = AiAgent.active.includes(:ai_provider).map do |agent|
  86. {
  87. id: agent.name.parameterize,
  88. object: "model",
  89. created: agent.created_at.to_i,
  90. owned_by: "railspress",
  91. permission: [],
  92. root: agent.name.parameterize,
  93. parent: nil
  94. }
  95. end
  96. render json: {
  97. object: "list",
  98. data: models_data
  99. }
  100. end
  101. # GET /v1/models/{id}
  102. def model
  103. agent = AiAgent.active.find_by("LOWER(REPLACE(name, ' ', '-')) = ?", params[:id].downcase)
  104. if agent
  105. render json: {
  106. id: params[:id],
  107. object: "model",
  108. created: agent.created_at.to_i,
  109. owned_by: "railspress",
  110. permission: [],
  111. root: params[:id],
  112. parent: nil
  113. }
  114. else
  115. render json: {
  116. error: {
  117. message: "The model '#{params[:id]}' does not exist",
  118. type: "invalid_request_error",
  119. code: "model_not_found"
  120. }
  121. }, status: :not_found
  122. end
  123. end
  124. private
  125. def authenticate_api_key
  126. auth_header = request.headers['Authorization']
  127. unless auth_header&.start_with?('Bearer ')
  128. render json: {
  129. error: {
  130. message: "Invalid API key provided",
  131. type: "invalid_request_error",
  132. code: "invalid_api_key"
  133. }
  134. }, status: :unauthorized
  135. return
  136. end
  137. api_key = auth_header.sub('Bearer ', '')
  138. # Find user by API key (assuming we have an api_key field on User model)
  139. @api_user = User.find_by(api_key: api_key)
  140. unless @api_user
  141. render json: {
  142. error: {
  143. message: "Invalid API key provided",
  144. type: "invalid_request_error",
  145. code: "invalid_api_key"
  146. }
  147. }, status: :unauthorized
  148. end
  149. end
  150. def set_agent
  151. model_name = params[:model]
  152. # Find agent by model name (parameterized)
  153. @agent = AiAgent.active.find_by("LOWER(REPLACE(name, ' ', '-')) = ?", model_name.downcase)
  154. unless @agent
  155. render json: {
  156. error: {
  157. message: "The model '#{model_name}' does not exist or is not available",
  158. type: "invalid_request_error",
  159. code: "model_not_found"
  160. }
  161. }, status: :not_found
  162. end
  163. end
  164. def validate_request
  165. unless params[:messages].is_a?(Array) && params[:messages].any?
  166. render json: {
  167. error: {
  168. message: "messages is required",
  169. type: "invalid_request_error",
  170. code: "missing_messages"
  171. }
  172. }, status: :bad_request
  173. return
  174. end
  175. # Validate message format
  176. params[:messages].each do |message|
  177. unless message['role'] && message['content']
  178. render json: {
  179. error: {
  180. message: "Each message must have 'role' and 'content'",
  181. type: "invalid_request_error",
  182. code: "invalid_message_format"
  183. }
  184. }, status: :bad_request
  185. return
  186. end
  187. end
  188. end
  189. def user_message
  190. # Get the last user message
  191. user_messages = params[:messages].select { |m| m['role'] == 'user' }
  192. user_messages.last&.dig('content') || ""
  193. end
  194. def system_message
  195. # Get the system message if present
  196. system_messages = params[:messages].select { |m| m['role'] == 'system' }
  197. system_messages.first&.dig('content') || ""
  198. end
  199. def full_prompt
  200. # Combine system message with agent prompt
  201. parts = []
  202. parts << system_message if system_message.present?
  203. parts << @agent.prompt if @agent.prompt.present?
  204. parts << "User Input: #{user_message}"
  205. parts.join("\n\n")
  206. end
  207. def build_context
  208. {
  209. temperature: params[:temperature] || @agent.ai_provider.temperature,
  210. max_tokens: params[:max_tokens] || @agent.ai_provider.max_tokens,
  211. model: params[:model],
  212. api_request: true
  213. }
  214. end
  215. def calculate_tokens(text)
  216. # Simple token estimation: ~4 characters per token
  217. (text.to_s.length / 4.0).ceil
  218. end
  219. def calculate_cost(prompt_tokens, completion_tokens)
  220. # Simple cost calculation based on provider
  221. total_tokens = prompt_tokens + completion_tokens
  222. case @agent.ai_provider.provider_type
  223. when 'openai'
  224. total_tokens * 0.00002
  225. when 'anthropic'
  226. total_tokens * 0.000015
  227. else
  228. total_tokens * 0.00001
  229. end
  230. end
  231. def generate_chat_id
  232. "chatcmpl_#{SecureRandom.hex(12)}"
  233. end
  234. end

app/controllers/api/v1/pages_controller.rb

0.0% lines covered

100.0% branches covered

126 relevant lines. 0 lines covered and 126 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class PagesController < BaseController
  4. before_action :set_page, only: [:show, :update, :destroy]
  5. # GET /api/v1/pages
  6. def index
  7. pages = Page.all
  8. # Filter by status
  9. pages = pages.where(status: params[:status]) if params[:status].present?
  10. # Filter by parent
  11. pages = pages.where(parent_id: params[:parent_id]) if params[:parent_id].present?
  12. # Root pages only
  13. pages = pages.root_pages if params[:root_only] == 'true'
  14. # Filter by channel
  15. if params[:channel].present?
  16. channel = Channel.find_by(slug: params[:channel])
  17. if channel
  18. # Get pages assigned to this channel or global pages (no channel assignment)
  19. pages = pages.left_joins(:channels)
  20. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  21. # Apply channel exclusions
  22. excluded_page_ids = channel.channel_overrides
  23. .exclusions
  24. .enabled
  25. .where(resource_type: 'Page')
  26. .pluck(:resource_id)
  27. pages = pages.where.not(id: excluded_page_ids) if excluded_page_ids.any?
  28. @current_channel = channel
  29. end
  30. end
  31. # Only published for non-authenticated or non-admin users
  32. unless current_api_user&.can_edit_others_posts?
  33. pages = pages.published
  34. end
  35. # Paginate
  36. @pages = paginate(pages.order(order: :asc, created_at: :desc))
  37. render_success(
  38. @pages.map { |page| page_serializer(page) },
  39. { filters: filter_meta }
  40. )
  41. end
  42. # GET /api/v1/pages/:id
  43. def show
  44. # Set current channel if channel parameter is provided
  45. if params[:channel].present?
  46. @current_channel = Channel.find_by(slug: params[:channel])
  47. end
  48. render_success(page_serializer(@page, detailed: true))
  49. end
  50. # POST /api/v1/pages
  51. def create
  52. unless current_api_user.can_publish?
  53. return render_error('You do not have permission to create pages', :forbidden)
  54. end
  55. @page = current_api_user.pages.build(page_params)
  56. if @page.save
  57. render_success(page_serializer(@page), {}, :created)
  58. else
  59. render_error(@page.errors.full_messages.join(', '))
  60. end
  61. end
  62. # PATCH/PUT /api/v1/pages/:id
  63. def update
  64. unless can_edit_page?
  65. return render_error('You do not have permission to edit this page', :forbidden)
  66. end
  67. if @page.update(page_params)
  68. render_success(page_serializer(@page))
  69. else
  70. render_error(@page.errors.full_messages.join(', '))
  71. end
  72. end
  73. # DELETE /api/v1/pages/:id
  74. def destroy
  75. unless current_api_user.can_delete_posts?
  76. return render_error('You do not have permission to delete pages', :forbidden)
  77. end
  78. @page.destroy
  79. render_success({ message: 'Page deleted successfully' })
  80. end
  81. private
  82. def set_page
  83. @page = Page.friendly.find(params[:id])
  84. end
  85. def can_edit_page?
  86. return true if current_api_user.can_edit_others_posts?
  87. @page.user_id == current_api_user.id
  88. end
  89. def page_params
  90. params.require(:page).permit(
  91. :title, :slug, :content, :status, :published_at,
  92. :parent_id, :order, :template, :meta_description, :meta_keywords
  93. )
  94. end
  95. def page_serializer(page, detailed: false)
  96. # Get channel slugs for this page
  97. channel_slugs = page.channels.pluck(:slug)
  98. # Start with basic page data
  99. page_data = {
  100. id: page.id,
  101. title: page.title,
  102. slug: page.slug,
  103. status: page.status,
  104. channels: channel_slugs,
  105. channel_context: @current_channel&.slug
  106. }
  107. # Add detailed fields if requested
  108. if detailed
  109. page_data.merge!({
  110. content: page.content,
  111. published_at: page.published_at,
  112. parent_id: page.parent_id,
  113. order: page.order,
  114. template: page.template,
  115. created_at: page.created_at,
  116. updated_at: page.updated_at,
  117. url: Rails.application.routes.url_helpers.page_url(page, host: request.host)
  118. })
  119. end
  120. # Apply channel overrides if current channel is set
  121. if @current_channel
  122. original_data = page_data.dup
  123. overridden_data, provenance = @current_channel.apply_overrides_to_data(
  124. original_data,
  125. 'Page',
  126. page.id,
  127. true
  128. )
  129. # Merge overridden data
  130. page_data.merge!(overridden_data)
  131. # Add provenance information
  132. page_data[:provenance] = provenance if provenance.present?
  133. end
  134. page_data
  135. end
  136. def filter_meta
  137. {
  138. status: params[:status],
  139. parent_id: params[:parent_id],
  140. root_only: params[:root_only],
  141. channel: params[:channel]
  142. }
  143. end
  144. end
  145. end
  146. end

app/controllers/api/v1/posts_controller.rb

0.0% lines covered

100.0% branches covered

142 relevant lines. 0 lines covered and 142 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class PostsController < BaseController
  4. before_action :set_post, only: [:show, :update, :destroy]
  5. # GET /api/v1/posts
  6. def index
  7. posts = Post.all
  8. # Filter by status
  9. posts = posts.where(status: params[:status]) if params[:status].present?
  10. # Filter by category
  11. posts = posts.by_category(params[:category]) if params[:category].present?
  12. # Filter by tag
  13. posts = posts.by_tag(params[:tag]) if params[:tag].present?
  14. # Filter by channel
  15. if params[:channel].present?
  16. channel = Channel.find_by(slug: params[:channel])
  17. if channel
  18. # Get posts assigned to this channel or global posts (no channel assignment)
  19. posts = posts.left_joins(:channels)
  20. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  21. # Apply channel exclusions
  22. excluded_post_ids = channel.channel_overrides
  23. .exclusions
  24. .enabled
  25. .where(resource_type: 'Post')
  26. .pluck(:resource_id)
  27. posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
  28. @current_channel = channel
  29. end
  30. elsif params[:auto_channel].present?
  31. # Use auto-detected channel from middleware
  32. channel = Channel.find_by(slug: params[:auto_channel])
  33. if channel
  34. posts = posts.left_joins(:channels)
  35. .where('channels.id = ? OR channels.id IS NULL', channel.id)
  36. excluded_post_ids = channel.channel_overrides
  37. .exclusions
  38. .enabled
  39. .where(resource_type: 'Post')
  40. .pluck(:resource_id)
  41. posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
  42. @current_channel = channel
  43. end
  44. end
  45. # Search
  46. posts = posts.search(params[:q]) if params[:q].present?
  47. # Only published for non-authenticated or non-admin users
  48. unless current_api_user&.can_edit_others_posts?
  49. posts = posts.published
  50. end
  51. # Paginate
  52. @posts = paginate(posts.order(created_at: :desc))
  53. render_success(
  54. @posts.map { |post| post_serializer(post) },
  55. { filters: filter_meta }
  56. )
  57. end
  58. # GET /api/v1/posts/:id
  59. def show
  60. # Set current channel if channel parameter is provided
  61. if params[:channel].present?
  62. @current_channel = Channel.find_by(slug: params[:channel])
  63. elsif params[:auto_channel].present?
  64. # Use auto-detected channel from middleware
  65. @current_channel = Channel.find_by(slug: params[:auto_channel])
  66. end
  67. render_success(post_serializer(@post, detailed: true))
  68. end
  69. # POST /api/v1/posts
  70. def create
  71. unless current_api_user.can_publish?
  72. return render_error('You do not have permission to create posts', :forbidden)
  73. end
  74. @post = current_api_user.posts.build(post_params)
  75. if @post.save
  76. render_success(post_serializer(@post), {}, :created)
  77. else
  78. render_error(@post.errors.full_messages.join(', '))
  79. end
  80. end
  81. # PATCH/PUT /api/v1/posts/:id
  82. def update
  83. unless can_edit_post?
  84. return render_error('You do not have permission to edit this post', :forbidden)
  85. end
  86. if @post.update(post_params)
  87. render_success(post_serializer(@post))
  88. else
  89. render_error(@post.errors.full_messages.join(', '))
  90. end
  91. end
  92. # DELETE /api/v1/posts/:id
  93. def destroy
  94. unless current_api_user.can_delete_posts?
  95. return render_error('You do not have permission to delete posts', :forbidden)
  96. end
  97. @post.destroy
  98. render_success({ message: 'Post deleted successfully' })
  99. end
  100. private
  101. def set_post
  102. @post = Post.friendly.find(params[:id])
  103. end
  104. def can_edit_post?
  105. return true if current_api_user.can_edit_others_posts?
  106. @post.user_id == current_api_user.id
  107. end
  108. def post_params
  109. params.require(:post).permit(
  110. :title, :slug, :content, :excerpt, :status, :published_at,
  111. :featured_image, :meta_description, :meta_keywords,
  112. category_ids: [], tag_ids: []
  113. )
  114. end
  115. def post_serializer(post, detailed: false)
  116. # Get channel slugs for this post
  117. channel_slugs = post.channels.pluck(:slug)
  118. # Start with basic post data
  119. post_data = {
  120. id: post.id,
  121. title: post.title,
  122. slug: post.slug,
  123. status: post.status,
  124. channels: channel_slugs,
  125. channel_context: @current_channel&.slug
  126. }
  127. # Add detailed fields if requested
  128. if detailed
  129. post_data.merge!({
  130. content: post.content,
  131. excerpt: post.excerpt,
  132. published_at: post.published_at,
  133. created_at: post.created_at,
  134. updated_at: post.updated_at,
  135. url: Rails.application.routes.url_helpers.blog_post_url(post, host: request.host)
  136. })
  137. end
  138. # Apply channel overrides if current channel is set
  139. if @current_channel
  140. original_data = post_data.dup
  141. overridden_data, provenance = @current_channel.apply_overrides_to_data(
  142. original_data,
  143. 'Post',
  144. post.id,
  145. true
  146. )
  147. # Merge overridden data
  148. post_data.merge!(overridden_data)
  149. # Add provenance information
  150. post_data[:provenance] = provenance if provenance.present?
  151. end
  152. post_data
  153. end
  154. def filter_meta
  155. {
  156. status: params[:status],
  157. category: params[:category],
  158. tag: params[:tag],
  159. search: params[:q],
  160. channel: params[:channel] || params[:auto_channel]
  161. }
  162. end
  163. end
  164. end
  165. end

app/controllers/api/v1/settings_controller.rb

0.0% lines covered

100.0% branches covered

62 relevant lines. 0 lines covered and 62 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class SettingsController < BaseController
  4. before_action :ensure_admin, except: [:index, :show]
  5. # GET /api/v1/settings
  6. def index
  7. settings = SiteSetting.all
  8. render_success(
  9. settings.map { |s| setting_serializer(s) }
  10. )
  11. end
  12. # GET /api/v1/settings/:key
  13. def show
  14. setting = SiteSetting.find_by!(key: params[:id])
  15. render_success(setting_serializer(setting))
  16. end
  17. # POST /api/v1/settings
  18. def create
  19. key = params[:setting][:key]
  20. value = params[:setting][:value]
  21. setting_type = params[:setting][:setting_type] || 'string'
  22. if SiteSetting.set(key, value, setting_type)
  23. setting = SiteSetting.find_by(key: key)
  24. render_success(setting_serializer(setting), {}, :created)
  25. else
  26. render_error('Failed to create setting')
  27. end
  28. end
  29. # PATCH/PUT /api/v1/settings/:key
  30. def update
  31. setting = SiteSetting.find_by!(key: params[:id])
  32. if setting.update(setting_params)
  33. render_success(setting_serializer(setting))
  34. else
  35. render_error(setting.errors.full_messages.join(', '))
  36. end
  37. end
  38. # DELETE /api/v1/settings/:key
  39. def destroy
  40. setting = SiteSetting.find_by!(key: params[:id])
  41. setting.destroy
  42. render_success({ message: 'Setting deleted successfully' })
  43. end
  44. # GET /api/v1/settings/get/:key
  45. def get_value
  46. value = SiteSetting.get(params[:key], params[:default])
  47. render_success({ key: params[:key], value: value })
  48. end
  49. private
  50. def ensure_admin
  51. unless current_api_user.administrator?
  52. render_error('Only administrators can manage settings', :forbidden)
  53. end
  54. end
  55. def setting_params
  56. params.require(:setting).permit(:key, :value, :setting_type)
  57. end
  58. def setting_serializer(setting)
  59. {
  60. key: setting.key,
  61. value: setting.typed_value,
  62. raw_value: setting.value,
  63. setting_type: setting.setting_type
  64. }
  65. end
  66. end
  67. end
  68. end

app/controllers/api/v1/simple_controller.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class SimpleController < BaseController
  4. def index
  5. render json: { message: "Hello World", success: true }
  6. end
  7. end
  8. end
  9. end

app/controllers/api/v1/subscribers_controller.rb

0.0% lines covered

100.0% branches covered

129 relevant lines. 0 lines covered and 129 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class SubscribersController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:create, :unsubscribe, :confirm]
  5. before_action :set_subscriber, only: [:show, :update, :destroy]
  6. # GET /api/v1/subscribers
  7. def index
  8. unless current_api_user.can_edit_others_posts?
  9. return render_error('You do not have permission to view subscribers', :forbidden)
  10. end
  11. subscribers = Subscriber.all
  12. # Filter by status
  13. subscribers = subscribers.where(status: params[:status]) if params[:status].present?
  14. # Filter by source
  15. subscribers = subscribers.by_source(params[:source]) if params[:source].present?
  16. # Search
  17. subscribers = subscribers.search(params[:q]) if params[:q].present?
  18. # Paginate
  19. @subscribers = paginate(subscribers.recent)
  20. render_success(
  21. @subscribers.map { |s| subscriber_serializer(s) }
  22. )
  23. end
  24. # GET /api/v1/subscribers/:id
  25. def show
  26. unless current_api_user.can_edit_others_posts?
  27. return render_error('You do not have permission to view subscribers', :forbidden)
  28. end
  29. render_success(subscriber_serializer(@subscriber, detailed: true))
  30. end
  31. # POST /api/v1/subscribers
  32. # Public endpoint for newsletter signups
  33. def create
  34. @subscriber = Subscriber.new(subscriber_create_params)
  35. @subscriber.status = 'pending'
  36. @subscriber.source = params[:source] || 'api'
  37. @subscriber.ip_address = request.remote_ip
  38. @subscriber.user_agent = request.user_agent
  39. if @subscriber.save
  40. render_success(
  41. {
  42. message: 'Successfully subscribed! Please check your email to confirm.',
  43. subscriber: subscriber_serializer(@subscriber)
  44. },
  45. {},
  46. :created
  47. )
  48. else
  49. render_error(@subscriber.errors.full_messages.join(', '))
  50. end
  51. end
  52. # PATCH/PUT /api/v1/subscribers/:id
  53. def update
  54. unless current_api_user.administrator?
  55. return render_error('Only administrators can update subscribers', :forbidden)
  56. end
  57. if @subscriber.update(subscriber_update_params)
  58. render_success(subscriber_serializer(@subscriber))
  59. else
  60. render_error(@subscriber.errors.full_messages.join(', '))
  61. end
  62. end
  63. # DELETE /api/v1/subscribers/:id
  64. def destroy
  65. unless current_api_user.administrator?
  66. return render_error('Only administrators can delete subscribers', :forbidden)
  67. end
  68. @subscriber.destroy
  69. render_success({ message: 'Subscriber deleted successfully' })
  70. end
  71. # POST /api/v1/subscribers/unsubscribe
  72. # Public endpoint for unsubscribing
  73. def unsubscribe
  74. subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
  75. unless subscriber
  76. return render_error('Invalid unsubscribe token', :not_found)
  77. end
  78. subscriber.unsubscribe!
  79. render_success({
  80. message: 'Successfully unsubscribed',
  81. email: subscriber.email
  82. })
  83. end
  84. # POST /api/v1/subscribers/confirm
  85. # Public endpoint for confirming subscription
  86. def confirm
  87. subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
  88. unless subscriber
  89. return render_error('Invalid confirmation token', :not_found)
  90. end
  91. if subscriber.confirmed_status?
  92. return render_success({
  93. message: 'Already confirmed',
  94. email: subscriber.email
  95. })
  96. end
  97. subscriber.confirm!
  98. render_success({
  99. message: 'Email confirmed! You are now subscribed.',
  100. email: subscriber.email
  101. })
  102. end
  103. # GET /api/v1/subscribers/stats
  104. def stats
  105. unless current_api_user.can_edit_others_posts?
  106. return render_error('You do not have permission to view stats', :forbidden)
  107. end
  108. render_success(Subscriber.stats)
  109. end
  110. private
  111. def set_subscriber
  112. @subscriber = Subscriber.find(params[:id])
  113. end
  114. def subscriber_create_params
  115. params.require(:subscriber).permit(:email, :name)
  116. end
  117. def subscriber_update_params
  118. params.require(:subscriber).permit(:email, :name, :status, :source, tags: [], lists: [])
  119. end
  120. def subscriber_serializer(subscriber, detailed: false)
  121. data = {
  122. id: subscriber.id,
  123. email: subscriber.email,
  124. name: subscriber.name,
  125. status: subscriber.status,
  126. source: subscriber.source,
  127. tags: subscriber.tags || [],
  128. lists: subscriber.lists || [],
  129. created_at: subscriber.created_at.iso8601
  130. }
  131. if detailed
  132. data.merge!(
  133. confirmed_at: subscriber.confirmed_at&.iso8601,
  134. unsubscribed_at: subscriber.unsubscribed_at&.iso8601,
  135. ip_address: subscriber.ip_address,
  136. user_agent: subscriber.user_agent,
  137. metadata: subscriber.metadata
  138. )
  139. end
  140. data
  141. end
  142. end
  143. end
  144. end

app/controllers/api/v1/system_controller.rb

0.0% lines covered

100.0% branches covered

74 relevant lines. 0 lines covered and 74 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class SystemController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:info]
  5. # GET /api/v1/system/info
  6. def info
  7. render_success({
  8. name: 'RailsPress API',
  9. version: 'v1',
  10. rails_version: Rails.version,
  11. ruby_version: RUBY_VERSION,
  12. environment: Rails.env,
  13. endpoints: {
  14. posts: api_v1_posts_url,
  15. pages: api_v1_pages_url,
  16. categories: api_v1_categories_url,
  17. tags: api_v1_tags_url,
  18. comments: api_v1_comments_url,
  19. media: api_v1_media_index_url,
  20. users: api_v1_users_url,
  21. menus: api_v1_menus_url,
  22. settings: api_v1_settings_url
  23. },
  24. documentation: 'https://github.com/railspress/api-docs'
  25. })
  26. end
  27. # GET /api/v1/system/stats
  28. def stats
  29. unless current_api_user.administrator?
  30. return render_error('Only administrators can view system stats', :forbidden)
  31. end
  32. render_success({
  33. content: {
  34. total_posts: Post.count,
  35. published_posts: Post.published.count,
  36. draft_posts: Post.draft_status.count,
  37. total_pages: Page.count,
  38. published_pages: Page.published.count,
  39. total_comments: Comment.count,
  40. approved_comments: Comment.approved.count,
  41. pending_comments: Comment.pending.count,
  42. spam_comments: Comment.spam.count
  43. },
  44. taxonomy: {
  45. categories: Term.for_taxonomy('category').count,
  46. tags: Term.for_taxonomy('post_tag').count
  47. },
  48. media: {
  49. total_files: Medium.count,
  50. images: Medium.images.count,
  51. videos: Medium.videos.count,
  52. documents: Medium.documents.count,
  53. total_size_mb: (Medium.sum(:file_size).to_f / 1024 / 1024).round(2)
  54. },
  55. users: {
  56. total: User.count,
  57. administrators: User.administrator.count,
  58. editors: User.editor.count,
  59. authors: User.author.count,
  60. contributors: User.contributor.count,
  61. subscribers: User.subscriber.count
  62. },
  63. system: {
  64. themes: Theme.count,
  65. active_theme: Theme.active.first&.name,
  66. plugins: Plugin.count,
  67. active_plugins: Plugin.active.count,
  68. menus: Menu.count,
  69. widgets: Widget.count,
  70. active_widgets: Widget.active.count
  71. }
  72. })
  73. end
  74. end
  75. end
  76. end

app/controllers/api/v1/tags_controller.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class TagsController < Api::V1::BaseController
  4. before_action :set_taxonomy
  5. before_action :set_tag, only: [:show, :update, :destroy]
  6. # GET /api/v1/tags
  7. def index
  8. tags = @taxonomy.terms.includes(:term_relationships).order(:name)
  9. render json: tags.map { |tag| tag_json(tag) }
  10. end
  11. # GET /api/v1/tags/:id
  12. def show
  13. render json: tag_json(@tag)
  14. end
  15. # POST /api/v1/tags
  16. def create
  17. @tag = @taxonomy.terms.new(tag_params)
  18. if @tag.save
  19. render json: tag_json(@tag), status: :created
  20. else
  21. render json: { errors: @tag.errors.full_messages }, status: :unprocessable_entity
  22. end
  23. end
  24. # PATCH/PUT /api/v1/tags/:id
  25. def update
  26. if @tag.update(tag_params)
  27. render json: tag_json(@tag)
  28. else
  29. render json: { errors: @tag.errors.full_messages }, status: :unprocessable_entity
  30. end
  31. end
  32. # DELETE /api/v1/tags/:id
  33. def destroy
  34. @tag.destroy
  35. head :no_content
  36. end
  37. private
  38. def set_taxonomy
  39. @taxonomy = Taxonomy.find_by!(slug: 'tag')
  40. rescue ActiveRecord::RecordNotFound
  41. render json: { error: 'Tag taxonomy not found' }, status: :not_found
  42. end
  43. def set_tag
  44. @tag = @taxonomy.terms.friendly.find(params[:id])
  45. rescue ActiveRecord::RecordNotFound
  46. render json: { error: 'Tag not found' }, status: :not_found
  47. end
  48. def tag_params
  49. params.require(:tag).permit(:name, :slug, :description, :meta)
  50. end
  51. def tag_json(tag)
  52. {
  53. id: tag.id,
  54. name: tag.name,
  55. slug: tag.slug,
  56. description: tag.description,
  57. count: tag.term_relationships.where(object_type: 'Post').count,
  58. meta: tag.meta,
  59. created_at: tag.created_at,
  60. updated_at: tag.updated_at,
  61. url: "/blog/tag/#{tag.slug}"
  62. }
  63. end
  64. end
  65. end
  66. end

app/controllers/api/v1/taxonomies_controller.rb

0.0% lines covered

100.0% branches covered

98 relevant lines. 0 lines covered and 98 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class TaxonomiesController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:index, :show, :terms]
  5. before_action :set_taxonomy, only: [:show, :update, :destroy, :terms]
  6. # GET /api/v1/taxonomies
  7. def index
  8. taxonomies = Taxonomy.all
  9. # Filter by object type
  10. taxonomies = taxonomies.where("object_types LIKE ?", "%#{params[:object_type]}%") if params[:object_type].present?
  11. # Filter by type
  12. case params[:type]
  13. when 'hierarchical'
  14. taxonomies = taxonomies.hierarchical
  15. when 'flat'
  16. taxonomies = taxonomies.flat
  17. end
  18. @taxonomies = paginate(taxonomies.order(:name))
  19. render_success(
  20. @taxonomies.map { |taxonomy| taxonomy_serializer(taxonomy) }
  21. )
  22. end
  23. # GET /api/v1/taxonomies/:id
  24. def show
  25. render_success(taxonomy_serializer(@taxonomy, detailed: true))
  26. end
  27. # GET /api/v1/taxonomies/:id/terms
  28. def terms
  29. terms = @taxonomy.terms.includes(:parent, :children)
  30. # Root terms only
  31. terms = terms.root_terms if params[:root_only] == 'true'
  32. @terms = paginate(terms.ordered)
  33. render_success(
  34. @terms.map { |term| term_serializer(term) }
  35. )
  36. end
  37. # POST /api/v1/taxonomies
  38. def create
  39. unless current_api_user.administrator?
  40. return render_error('Only administrators can create taxonomies', :forbidden)
  41. end
  42. @taxonomy = Taxonomy.new(taxonomy_params)
  43. if @taxonomy.save
  44. render_success(taxonomy_serializer(@taxonomy), {}, :created)
  45. else
  46. render_error(@taxonomy.errors.full_messages.join(', '))
  47. end
  48. end
  49. # PATCH/PUT /api/v1/taxonomies/:id
  50. def update
  51. unless current_api_user.administrator?
  52. return render_error('Only administrators can update taxonomies', :forbidden)
  53. end
  54. if @taxonomy.update(taxonomy_params)
  55. render_success(taxonomy_serializer(@taxonomy))
  56. else
  57. render_error(@taxonomy.errors.full_messages.join(', '))
  58. end
  59. end
  60. # DELETE /api/v1/taxonomies/:id
  61. def destroy
  62. unless current_api_user.administrator?
  63. return render_error('Only administrators can delete taxonomies', :forbidden)
  64. end
  65. @taxonomy.destroy
  66. render_success({ message: 'Taxonomy deleted successfully' })
  67. end
  68. private
  69. def set_taxonomy
  70. @taxonomy = Taxonomy.friendly.find(params[:id])
  71. end
  72. def taxonomy_params
  73. params.require(:taxonomy).permit(:name, :slug, :description, :hierarchical, object_types: [], settings: {})
  74. end
  75. def taxonomy_serializer(taxonomy, detailed: false)
  76. data = {
  77. id: taxonomy.id,
  78. name: taxonomy.name,
  79. slug: taxonomy.slug,
  80. description: taxonomy.description,
  81. hierarchical: taxonomy.hierarchical?,
  82. object_types: taxonomy.object_types,
  83. term_count: taxonomy.term_count
  84. }
  85. if detailed
  86. data.merge!(
  87. terms: taxonomy.root_terms.map { |term| term_serializer(term) },
  88. settings: taxonomy.settings
  89. )
  90. end
  91. data
  92. end
  93. def term_serializer(term)
  94. {
  95. id: term.id,
  96. name: term.name,
  97. slug: term.slug,
  98. description: term.description,
  99. count: term.count,
  100. parent_id: term.parent_id,
  101. parent: term.parent ? { id: term.parent.id, name: term.parent.name } : nil,
  102. children_count: term.children.count
  103. }
  104. end
  105. end
  106. end
  107. end

app/controllers/api/v1/terms_controller.rb

0.0% lines covered

100.0% branches covered

87 relevant lines. 0 lines covered and 87 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class TermsController < BaseController
  4. skip_before_action :authenticate_api_user!, only: [:index, :show]
  5. before_action :set_taxonomy, except: [:index, :show]
  6. before_action :set_term, only: [:show, :update, :destroy]
  7. # GET /api/v1/terms
  8. def index
  9. terms = Term.includes(:taxonomy, :parent)
  10. # Filter by taxonomy
  11. terms = terms.for_taxonomy(params[:taxonomy]) if params[:taxonomy].present?
  12. # Search
  13. terms = terms.where('name LIKE ?', "%#{params[:q]}%") if params[:q].present?
  14. @terms = paginate(terms.ordered)
  15. render_success(
  16. @terms.map { |term| term_serializer(term) }
  17. )
  18. end
  19. # GET /api/v1/terms/:id
  20. def show
  21. render_success(term_serializer(@term, detailed: true))
  22. end
  23. # POST /api/v1/taxonomies/:taxonomy_id/terms
  24. def create
  25. unless current_api_user.can_edit_others_posts?
  26. return render_error('You do not have permission to create terms', :forbidden)
  27. end
  28. @term = @taxonomy.terms.build(term_params)
  29. if @term.save
  30. render_success(term_serializer(@term), {}, :created)
  31. else
  32. render_error(@term.errors.full_messages.join(', '))
  33. end
  34. end
  35. # PATCH/PUT /api/v1/taxonomies/:taxonomy_id/terms/:id
  36. def update
  37. unless current_api_user.can_edit_others_posts?
  38. return render_error('You do not have permission to update terms', :forbidden)
  39. end
  40. if @term.update(term_params)
  41. render_success(term_serializer(@term))
  42. else
  43. render_error(@term.errors.full_messages.join(', '))
  44. end
  45. end
  46. # DELETE /api/v1/taxonomies/:taxonomy_id/terms/:id
  47. def destroy
  48. unless current_api_user.administrator?
  49. return render_error('Only administrators can delete terms', :forbidden)
  50. end
  51. @term.destroy
  52. render_success({ message: 'Term deleted successfully' })
  53. end
  54. private
  55. def set_taxonomy
  56. @taxonomy = Taxonomy.friendly.find(params[:taxonomy_id])
  57. end
  58. def set_term
  59. if params[:taxonomy_id]
  60. @term = @taxonomy.terms.friendly.find(params[:id])
  61. else
  62. @term = Term.friendly.find(params[:id])
  63. end
  64. end
  65. def term_params
  66. params.require(:term).permit(:name, :slug, :description, :parent_id, metadata: {})
  67. end
  68. def term_serializer(term, detailed: false)
  69. data = {
  70. id: term.id,
  71. name: term.name,
  72. slug: term.slug,
  73. description: term.description,
  74. count: term.count,
  75. taxonomy: {
  76. id: term.taxonomy.id,
  77. name: term.taxonomy.name,
  78. slug: term.taxonomy.slug
  79. },
  80. parent: term.parent ? { id: term.parent.id, name: term.parent.name, slug: term.parent.slug } : nil,
  81. children_count: term.children.count
  82. }
  83. if detailed
  84. data.merge!(
  85. children: term.children.map { |c| { id: c.id, name: c.name, slug: c.slug } },
  86. breadcrumbs: term.breadcrumbs.map { |b| { id: b.id, name: b.name, slug: b.slug } },
  87. metadata: term.metadata
  88. )
  89. end
  90. data
  91. end
  92. end
  93. end
  94. end

app/controllers/api/v1/test_controller.rb

0.0% lines covered

100.0% branches covered

31 relevant lines. 0 lines covered and 31 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Api
  2. module V1
  3. class TestController < BaseController
  4. def index
  5. begin
  6. posts = Post.includes(:user, :categories, :tags, :comments, :channels)
  7. posts = posts.where(status: 'published')
  8. posts = posts.left_joins(:channels)
  9. posts = posts.order(created_at: :desc)
  10. @posts = posts.limit(10)
  11. render_success(
  12. @posts.map { |post| simple_serializer(post) },
  13. { message: 'Test successful' }
  14. )
  15. rescue => e
  16. render_error("Error: #{e.message}")
  17. end
  18. end
  19. private
  20. def simple_serializer(post)
  21. {
  22. id: post.id,
  23. title: post.title,
  24. slug: post.slug,
  25. status: post.status,
  26. channels: post.channels.map { |c| c.slug }
  27. }
  28. end
  29. end
  30. end
  31. end

app/controllers/api/v1/themes_controller.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::ThemesController < Api::V1::BaseController
  2. before_action :authenticate_api_key
  3. before_action :set_theme, only: [:show, :screenshot]
  4. skip_before_action :set_content_type, only: [:screenshot]
  5. # GET /api/v1/themes
  6. def index
  7. @themes = Theme.includes(:published_version)
  8. render json: {
  9. themes: @themes.map do |theme|
  10. {
  11. id: theme.id,
  12. name: theme.name,
  13. slug: theme.slug,
  14. description: theme.description,
  15. version: theme.version,
  16. active: theme.active?,
  17. screenshot_url: api_v1_theme_screenshot_url(theme.id),
  18. created_at: theme.created_at,
  19. updated_at: theme.updated_at
  20. }
  21. end
  22. }
  23. end
  24. # GET /api/v1/themes/:id
  25. def show
  26. render json: {
  27. theme: {
  28. id: @theme.id,
  29. name: @theme.name,
  30. slug: @theme.slug,
  31. description: @theme.description,
  32. version: @theme.version,
  33. active: @theme.active?,
  34. screenshot_url: api_v1_theme_screenshot_url(@theme.id),
  35. created_at: @theme.created_at,
  36. updated_at: @theme.updated_at
  37. }
  38. }
  39. end
  40. # GET /api/v1/themes/:id/screenshot
  41. def screenshot
  42. cache_key = "theme_screenshot_#{@theme.id}"
  43. Rails.logger.info "API: Cache key: #{cache_key}"
  44. # Try to get from cache first
  45. cached_screenshot = Rails.cache.read(cache_key)
  46. Rails.logger.info "API: Cache read result: #{cached_screenshot ? 'HIT' : 'MISS'}"
  47. if cached_screenshot
  48. Rails.logger.info "API: Serving cached screenshot for theme #{@theme.id} (size: #{cached_screenshot.bytesize} bytes)"
  49. send_data cached_screenshot,
  50. type: 'image/png',
  51. disposition: 'inline',
  52. filename: "theme_#{@theme.id}_screenshot.png"
  53. return
  54. end
  55. begin
  56. # Generate new screenshot
  57. Rails.logger.info "API: Generating new screenshot for theme #{@theme.id}"
  58. screenshot_data = ScreenshotService.capture_theme_screenshot_data(@theme, {
  59. width: 1200,
  60. height: 800,
  61. format: :png
  62. })
  63. # Ferrum returns base64-encoded PNG data, decode it to binary
  64. if screenshot_data.match?(/^[A-Za-z0-9+\/]*={0,2}$/)
  65. screenshot_data = Base64.decode64(screenshot_data)
  66. end
  67. # Cache the screenshot for 1 hour
  68. Rails.logger.info "API: Caching screenshot for theme #{@theme.id} (size: #{screenshot_data.bytesize} bytes)"
  69. Rails.cache.write(cache_key, screenshot_data, expires_in: 1.hour)
  70. send_data screenshot_data,
  71. type: 'image/png',
  72. disposition: 'inline',
  73. filename: "theme_#{@theme.id}_screenshot.png"
  74. rescue => e
  75. Rails.logger.error "API: Screenshot capture failed for theme #{@theme.id}: #{e.message}"
  76. render json: {
  77. error: {
  78. message: "Failed to capture screenshot",
  79. type: "screenshot_error",
  80. code: "screenshot_failed",
  81. details: e.message
  82. }
  83. }, status: :internal_server_error
  84. end
  85. end
  86. private
  87. def set_theme
  88. @theme = Theme.find(params[:id])
  89. rescue ActiveRecord::RecordNotFound
  90. render json: {
  91. error: {
  92. message: "Theme not found",
  93. type: "not_found_error",
  94. code: "theme_not_found"
  95. }
  96. }, status: :not_found
  97. end
  98. end

app/controllers/api/v1/uploads_controller.rb

0.0% lines covered

100.0% branches covered

172 relevant lines. 0 lines covered and 172 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Api::V1::UploadsController < ApplicationController
  2. before_action :authenticate_user!
  3. before_action :set_upload_security
  4. before_action :validate_upload_permissions
  5. # POST /api/v1/uploads
  6. def create
  7. @upload = Upload.new(upload_params)
  8. @upload.user = current_user
  9. @upload.storage_provider = StorageProvider.active.first
  10. # Security validation
  11. unless @upload_security.file_allowed?(@upload.file)
  12. render json: {
  13. error: 'File not allowed',
  14. details: 'File type, size, or extension is not permitted'
  15. }, status: :forbidden
  16. return
  17. end
  18. # Check for suspicious files
  19. if @upload_security.file_suspicious?(@upload.file)
  20. if @upload_security.quarantine_suspicious?
  21. @upload.quarantined = true
  22. @upload.quarantine_reason = 'Suspicious file pattern detected'
  23. else
  24. render json: {
  25. error: 'File rejected',
  26. details: 'File appears to be suspicious and has been blocked'
  27. }, status: :forbidden
  28. return
  29. end
  30. end
  31. if @upload.save
  32. # Trigger plugin hooks
  33. Railspress::PluginSystem.do_action('upload_created', @upload)
  34. render json: {
  35. id: @upload.id,
  36. title: @upload.title,
  37. filename: @upload.filename,
  38. content_type: @upload.content_type,
  39. file_size: @upload.file_size,
  40. url: @upload.url,
  41. quarantined: @upload.quarantined?,
  42. created_at: @upload.created_at
  43. }, status: :created
  44. else
  45. render json: {
  46. error: 'Upload failed',
  47. details: @upload.errors.full_messages
  48. }, status: :unprocessable_entity
  49. end
  50. end
  51. # GET /api/v1/uploads
  52. def index
  53. @uploads = current_user.uploads.includes(:storage_provider)
  54. # Filter by plugin if specified
  55. if params[:plugin].present?
  56. @uploads = @uploads.where("title LIKE ? OR description LIKE ?",
  57. "%#{params[:plugin]}%", "%#{params[:plugin]}%")
  58. end
  59. # Filter by file type
  60. if params[:file_type].present?
  61. case params[:file_type]
  62. when 'image'
  63. @uploads = @uploads.joins(:file_attachment)
  64. .where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] })
  65. when 'document'
  66. @uploads = @uploads.joins(:file_attachment)
  67. .where(active_storage_blobs: { content_type: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] })
  68. when 'archive'
  69. @uploads = @uploads.joins(:file_attachment)
  70. .where(active_storage_blobs: { content_type: ['application/zip', 'application/x-rar-compressed'] })
  71. end
  72. end
  73. # Filter by quarantine status
  74. if params[:quarantined].present?
  75. @uploads = @uploads.where(quarantined: params[:quarantined] == 'true')
  76. end
  77. # Pagination
  78. @uploads = @uploads.page(params[:page]).per(params[:per_page] || 20)
  79. render json: {
  80. uploads: @uploads.map do |upload|
  81. {
  82. id: upload.id,
  83. title: upload.title,
  84. description: upload.description,
  85. filename: upload.filename,
  86. content_type: upload.content_type,
  87. file_size: upload.file_size,
  88. url: upload.url,
  89. quarantined: upload.quarantined?,
  90. quarantine_reason: upload.quarantine_reason,
  91. created_at: upload.created_at,
  92. updated_at: upload.updated_at
  93. }
  94. end,
  95. pagination: {
  96. current_page: @uploads.current_page,
  97. total_pages: @uploads.total_pages,
  98. total_count: @uploads.total_count,
  99. per_page: @uploads.limit_value
  100. }
  101. }
  102. end
  103. # GET /api/v1/uploads/:id
  104. def show
  105. @upload = current_user.uploads.find(params[:id])
  106. render json: {
  107. id: @upload.id,
  108. title: @upload.title,
  109. description: @upload.description,
  110. filename: @upload.filename,
  111. content_type: @upload.content_type,
  112. file_size: @upload.file_size,
  113. url: @upload.url,
  114. quarantined: @upload.quarantined?,
  115. quarantine_reason: @upload.quarantine_reason,
  116. created_at: @upload.created_at,
  117. updated_at: @upload.updated_at,
  118. storage_provider: {
  119. id: @upload.storage_provider.id,
  120. name: @upload.storage_provider.name,
  121. type: @upload.storage_provider.provider_type
  122. }
  123. }
  124. end
  125. # PATCH/PUT /api/v1/uploads/:id
  126. def update
  127. @upload = current_user.uploads.find(params[:id])
  128. if @upload.update(upload_params.except(:file))
  129. render json: {
  130. id: @upload.id,
  131. title: @upload.title,
  132. description: @upload.description,
  133. filename: @upload.filename,
  134. content_type: @upload.content_type,
  135. file_size: @upload.file_size,
  136. url: @upload.url,
  137. quarantined: @upload.quarantined?,
  138. quarantine_reason: @upload.quarantine_reason,
  139. updated_at: @upload.updated_at
  140. }
  141. else
  142. render json: {
  143. error: 'Update failed',
  144. details: @upload.errors.full_messages
  145. }, status: :unprocessable_entity
  146. end
  147. end
  148. # DELETE /api/v1/uploads/:id
  149. def destroy
  150. @upload = current_user.uploads.find(params[:id])
  151. @upload.destroy!
  152. head :no_content
  153. end
  154. # POST /api/v1/uploads/:id/approve
  155. def approve
  156. @upload = current_user.uploads.find(params[:id])
  157. if @upload.quarantined?
  158. @upload.update!(quarantined: false, quarantine_reason: nil)
  159. render json: { message: 'Upload approved and released from quarantine' }
  160. else
  161. render json: { error: 'Upload is not quarantined' }, status: :bad_request
  162. end
  163. end
  164. # POST /api/v1/uploads/:id/reject
  165. def reject
  166. @upload = current_user.uploads.find(params[:id])
  167. if @upload.quarantined?
  168. @upload.destroy!
  169. render json: { message: 'Upload rejected and deleted' }
  170. else
  171. render json: { error: 'Upload is not quarantined' }, status: :bad_request
  172. end
  173. end
  174. private
  175. def set_upload_security
  176. @upload_security = UploadSecurity.current
  177. end
  178. def validate_upload_permissions
  179. unless current_user.can_upload_files?
  180. render json: { error: 'Insufficient permissions' }, status: :forbidden
  181. end
  182. end
  183. def upload_params
  184. params.require(:upload).permit(:title, :description, :alt_text, :file)
  185. end
  186. end

app/controllers/application_controller.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApplicationController < ActionController::Base
  2. include Themeable
  3. include Pundit::Authorization
  4. # Set current tenant for multi-tenancy
  5. set_current_tenant_through_filter
  6. before_action :set_current_tenant
  7. # Prevent CSRF attacks by raising an exception
  8. protect_from_forgery with: :exception
  9. # Pundit authorization
  10. rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
  11. private
  12. def set_current_tenant
  13. tenant = nil
  14. # Priority 1: Use logged-in user's tenant
  15. if user_signed_in? && current_user.tenant
  16. tenant = current_user.tenant
  17. # Priority 2: Find tenant by domain or subdomain
  18. elsif request.host != 'localhost'
  19. tenant = Tenant.find_by(domain: request.host) ||
  20. Tenant.find_by(subdomain: request.subdomains.first)
  21. # Priority 3: Use default tenant for localhost/frontend
  22. elsif !request.path.start_with?('/admin')
  23. tenant = Tenant.first || Tenant.create!(
  24. name: 'RailsPress Default',
  25. domain: 'localhost',
  26. theme: 'nordic',
  27. storage_type: 'local'
  28. )
  29. end
  30. # Set current tenant - use tenant_id to avoid acts_as_tenant issues
  31. if tenant
  32. # Create a simple object that responds to tenant_id
  33. tenant_wrapper = OpenStruct.new(tenant_id: tenant.id, id: tenant.id)
  34. ActsAsTenant.current_tenant = tenant_wrapper
  35. end
  36. # Store tenant in instance variable for views
  37. @current_tenant = tenant
  38. # Log tenant context
  39. Rails.logger.info "Request tenant: #{tenant&.name || 'None (Global)'}" if tenant
  40. end
  41. helper_method :current_tenant
  42. def current_tenant
  43. @current_tenant || ActsAsTenant.current_tenant
  44. end
  45. def user_not_authorized
  46. flash[:alert] = "You are not authorized to perform this action."
  47. redirect_to(request.referrer || root_path)
  48. end
  49. end

app/controllers/comments_controller.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CommentsController < ApplicationController
  2. before_action :set_commentable
  3. def create
  4. @comment = @commentable.comments.build(comment_params)
  5. @comment.user = current_user if user_signed_in?
  6. @comment.status = :pending
  7. if @comment.save
  8. redirect_back fallback_location: root_path, notice: 'Your comment has been submitted and is awaiting moderation.'
  9. else
  10. redirect_back fallback_location: root_path, alert: 'There was an error submitting your comment.'
  11. end
  12. end
  13. private
  14. def set_commentable
  15. if params[:post_id]
  16. @commentable = Post.friendly.find(params[:post_id])
  17. elsif params[:page_id]
  18. @commentable = Page.friendly.find(params[:page_id])
  19. end
  20. end
  21. def comment_params
  22. params.require(:comment).permit(:content, :author_name, :author_email, :author_url, :parent_id)
  23. end
  24. end

app/controllers/concerns/liquid_renderable.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module LiquidRenderable
  2. extend ActiveSupport::Concern
  3. included do
  4. before_action :setup_liquid_renderer
  5. end
  6. private
  7. def setup_liquid_renderer
  8. # No longer needed - we'll use ThemeVersionLoader directly
  9. end
  10. def current_theme_name
  11. Railspress::ThemeLoader.current_theme || 'nordic'
  12. end
  13. def render_liquid(template, assigns = {}, options = {})
  14. layout = options[:layout].nil? ? 'theme' : options[:layout]
  15. assigns_with_context = assigns.merge(
  16. current_user: current_user,
  17. request_path: request.path,
  18. flash: flash.to_hash,
  19. params: params.to_unsafe_h,
  20. assets: FrontendThemeRenderer.load_assets
  21. )
  22. # Use FrontendThemeRenderer to render from PublishedThemeVersion
  23. html = FrontendThemeRenderer.render_template(template, assigns_with_context)
  24. # Inject admin bar for logged-in users
  25. if user_signed_in? && html.include?('<body')
  26. admin_bar_html = render_to_string(
  27. partial: 'shared/admin_bar',
  28. layout: false,
  29. formats: [:html]
  30. )
  31. # Inject right after <body> tag
  32. html = html.sub(/(<body[^>]*>)/i, "\\1\n#{admin_bar_html}")
  33. end
  34. render html: html.html_safe, layout: false, status: options[:status] || :ok
  35. end
  36. def render_liquid_error(status_code)
  37. template = case status_code
  38. when 404
  39. '404'
  40. when 500
  41. '500'
  42. else
  43. 'error'
  44. end
  45. render_liquid(template, { status_code: status_code }, layout: 'error', status: status_code)
  46. end
  47. end

app/controllers/concerns/themeable.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Themeable
  2. extend ActiveSupport::Concern
  3. included do
  4. before_action :load_theme
  5. helper_method :current_theme, :theme_option, :theme_config
  6. end
  7. private
  8. def load_theme
  9. # Theme is already loaded by initializer, but we can add controller-specific logic here
  10. @current_theme = Railspress::ThemeLoader.current_theme
  11. end
  12. def current_theme
  13. @current_theme || Railspress::ThemeLoader.current_theme
  14. end
  15. def theme_option(key, default = nil)
  16. config = theme_config
  17. config.dig('settings', key) || default
  18. end
  19. def theme_config
  20. @theme_config ||= Railspress::ThemeLoader.theme_config
  21. end
  22. def theme_name
  23. theme_config['name'] || 'Default Theme'
  24. end
  25. def theme_version
  26. theme_config['version'] || '1.0.0'
  27. end
  28. def theme_supports?(feature)
  29. theme_config.dig('features')&.include?(feature.to_s) || false
  30. end
  31. end

app/controllers/csp_reports_controller.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CspReportsController < ApplicationController
  2. skip_before_action :verify_authenticity_token
  3. skip_before_action :set_current_tenant
  4. def create
  5. if Rails.env.development?
  6. Rails.logger.warn "CSP Violation: #{csp_report_params.inspect}"
  7. end
  8. # In production, you might want to store these or send to monitoring service
  9. # CspViolation.create(report: csp_report_params) if Rails.env.production?
  10. head :no_content
  11. end
  12. private
  13. def csp_report_params
  14. JSON.parse(request.body.read)
  15. rescue JSON::ParserError
  16. {}
  17. end
  18. end

app/controllers/feeds_controller.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class FeedsController < ApplicationController
  2. before_action :set_cache_headers
  3. # GET /feed or /feed.rss
  4. def posts
  5. @posts = Post.published_status.visible_to_public
  6. .order(published_at: :desc)
  7. .limit(50)
  8. .includes(:user, :terms)
  9. respond_to do |format|
  10. format.rss { render layout: false }
  11. format.atom { render layout: false }
  12. format.xml { render :posts, layout: false }
  13. end
  14. end
  15. # GET /feed/posts.rss
  16. def posts_rss
  17. posts
  18. end
  19. # GET /feed/comments.rss
  20. def comments
  21. @comments = Comment.where(status: 'approved')
  22. .order(created_at: :desc)
  23. .limit(50)
  24. .includes(:commentable)
  25. respond_to do |format|
  26. format.rss { render layout: false }
  27. end
  28. end
  29. # GET /feed/category/:slug.rss
  30. def category
  31. @category = Term.for_taxonomy('category').friendly.find(params[:slug])
  32. @posts = @category.posts.published_status.visible_to_public
  33. .order(published_at: :desc)
  34. .limit(50)
  35. .includes(:user, :taxonomies)
  36. @title_suffix = "Category: #{@category.name}"
  37. respond_to do |format|
  38. format.rss { render :posts, layout: false }
  39. end
  40. end
  41. # GET /feed/tag/:slug.rss
  42. def tag
  43. tag_taxonomy = Taxonomy.find_by!(slug: 'tag')
  44. @tag = tag_taxonomy.terms.friendly.find(params[:slug])
  45. @posts = Post.published_status.visible_to_public
  46. .joins(:term_relationships)
  47. .where(term_relationships: { term_id: @tag.id })
  48. .order(published_at: :desc)
  49. .distinct
  50. .limit(50)
  51. .includes(:user, :terms)
  52. @title_suffix = "Tag: #{@tag.name}"
  53. respond_to do |format|
  54. format.rss { render :posts, layout: false }
  55. end
  56. end
  57. # GET /feed/author/:id.rss
  58. def author
  59. @user = User.find(params[:id])
  60. @posts = @user.posts.published_status.visible_to_public
  61. .order(published_at: :desc)
  62. .limit(50)
  63. .includes(:user, :terms)
  64. @title_suffix = "Author: #{@user.name || @user.email}"
  65. respond_to do |format|
  66. format.rss { render :posts, layout: false }
  67. end
  68. end
  69. # GET /feed/pages.rss
  70. def pages
  71. @pages = Page.published_status.visible_to_public
  72. .order(published_at: :desc)
  73. .limit(50)
  74. respond_to do |format|
  75. format.rss { render layout: false }
  76. end
  77. end
  78. private
  79. def set_cache_headers
  80. # Cache RSS feeds for 1 hour
  81. expires_in 1.hour, public: true
  82. end
  83. end

app/controllers/gdpr_controller.rb

0.0% lines covered

100.0% branches covered

189 relevant lines. 0 lines covered and 189 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class GdprController < ApplicationController
  3. before_action :set_cors_headers
  4. before_action :validate_gdpr_request, only: [:data_access, :data_deletion, :data_portability]
  5. # GET /gdpr/privacy-policy
  6. def privacy_policy
  7. @privacy_info = GdprComplianceService.get_privacy_policy_info
  8. respond_to do |format|
  9. format.html { render layout: 'application' }
  10. format.json { render json: @privacy_info }
  11. end
  12. end
  13. # POST /gdpr/consent
  14. def update_consent
  15. session_id = get_or_create_session_id
  16. consent_data = params[:consent] || {}
  17. # Validate consent data
  18. if consent_data.empty?
  19. render json: { error: 'Consent data is required' }, status: :bad_request
  20. return
  21. end
  22. # Store consent
  23. GdprComplianceService.store_consent(session_id, consent_data)
  24. # Set consent cookies
  25. set_consent_cookies(consent_data)
  26. render json: {
  27. success: true,
  28. message: 'Consent preferences updated',
  29. session_id: session_id
  30. }
  31. rescue => e
  32. Rails.logger.error "Failed to update consent: #{e.message}"
  33. render json: { error: 'Failed to update consent preferences' }, status: :internal_server_error
  34. end
  35. # POST /gdpr/data-access
  36. def data_access
  37. session_id = get_or_create_session_id
  38. request_data = {
  39. request_type: 'data_access',
  40. timestamp: Time.current,
  41. ip_address: request.ip,
  42. user_agent: request.user_agent
  43. }
  44. # Handle data access request
  45. data = GdprComplianceService.handle_data_access_request(session_id, request_data)
  46. respond_to do |format|
  47. format.json { render json: data }
  48. format.html {
  49. # For HTML requests, redirect to download page
  50. redirect_to gdpr_download_path(session_id: session_id)
  51. }
  52. end
  53. rescue => e
  54. Rails.logger.error "Failed to handle data access request: #{e.message}"
  55. render json: { error: 'Failed to process data access request' }, status: :internal_server_error
  56. end
  57. # POST /gdpr/data-deletion
  58. def data_deletion
  59. session_id = get_or_create_session_id
  60. request_data = {
  61. request_type: 'data_deletion',
  62. timestamp: Time.current,
  63. ip_address: request.ip,
  64. user_agent: request.user_agent
  65. }
  66. # Handle data deletion request
  67. result = GdprComplianceService.handle_data_deletion_request(session_id, request_data)
  68. render json: result
  69. rescue => e
  70. Rails.logger.error "Failed to handle data deletion request: #{e.message}"
  71. render json: { error: 'Failed to process data deletion request' }, status: :internal_server_error
  72. end
  73. # POST /gdpr/data-portability
  74. def data_portability
  75. session_id = get_or_create_session_id
  76. request_data = {
  77. request_type: 'data_portability',
  78. timestamp: Time.current,
  79. ip_address: request.ip,
  80. user_agent: request.user_agent
  81. }
  82. # Handle data portability request
  83. data = GdprComplianceService.handle_data_portability_request(session_id, request_data)
  84. respond_to do |format|
  85. format.json { render json: data }
  86. format.html {
  87. # For HTML requests, redirect to download page
  88. redirect_to gdpr_download_path(session_id: session_id, format: :json)
  89. }
  90. end
  91. rescue => e
  92. Rails.logger.error "Failed to handle data portability request: #{e.message}"
  93. render json: { error: 'Failed to process data portability request' }, status: :internal_server_error
  94. end
  95. # GET /gdpr/download/:session_id
  96. def download_data
  97. session_id = params[:session_id]
  98. if session_id.blank?
  99. render json: { error: 'Session ID is required' }, status: :bad_request
  100. return
  101. end
  102. # Get data for download
  103. data = GdprComplianceService.handle_data_access_request(session_id)
  104. respond_to do |format|
  105. format.json {
  106. send_data JSON.pretty_generate(data),
  107. filename: "railspress_data_#{session_id}_#{Date.current}.json",
  108. type: 'application/json'
  109. }
  110. format.html {
  111. @data = data
  112. @session_id = session_id
  113. render layout: 'application'
  114. }
  115. end
  116. rescue => e
  117. Rails.logger.error "Failed to download data: #{e.message}"
  118. render json: { error: 'Failed to download data' }, status: :internal_server_error
  119. end
  120. # POST /gdpr/contact-dpo
  121. def contact_dpo
  122. # This would typically send an email to the DPO
  123. # For now, we'll just log the request
  124. session_id = get_or_create_session_id
  125. request_data = {
  126. request_type: 'dpo_contact',
  127. timestamp: Time.current,
  128. ip_address: request.ip,
  129. user_agent: request.user_agent,
  130. message: params[:message],
  131. email: params[:email]
  132. }
  133. # Log the DPO contact request
  134. AnalyticsEvent.create!(
  135. event_name: 'gdpr_dpo_contact_request',
  136. properties: request_data,
  137. session_id: session_id,
  138. tenant: ActsAsTenant.current_tenant
  139. )
  140. render json: {
  141. success: true,
  142. message: 'Your message has been sent to our Data Protection Officer',
  143. dpo_email: SiteSetting.get('dpo_email', 'dpo@railspress.com')
  144. }
  145. rescue => e
  146. Rails.logger.error "Failed to contact DPO: #{e.message}"
  147. render json: { error: 'Failed to send message to DPO' }, status: :internal_server_error
  148. end
  149. # GET /gdpr/consent-status
  150. def consent_status
  151. session_id = get_or_create_session_id
  152. consent_status = {
  153. session_id: session_id,
  154. analytics_consent: GdprComplianceService.has_valid_consent?(session_id, 'analytics'),
  155. marketing_consent: GdprComplianceService.has_valid_consent?(session_id, 'marketing'),
  156. essential_consent: true, # Always true for essential cookies
  157. gdpr_applies: GdprComplianceService.gdpr_applies?(request),
  158. privacy_policy_url: gdpr_privacy_policy_url
  159. }
  160. render json: consent_status
  161. end
  162. private
  163. def set_cors_headers
  164. headers['Access-Control-Allow-Origin'] = '*'
  165. headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
  166. headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-CSRF-Token'
  167. end
  168. def validate_gdpr_request
  169. # Check if GDPR applies
  170. unless GdprComplianceService.gdpr_applies?(request)
  171. render json: { error: 'GDPR does not apply to this request' }, status: :forbidden
  172. return
  173. end
  174. # Check rate limiting (prevent abuse)
  175. session_id = get_or_create_session_id
  176. rate_limit_key = "gdpr_request_rate_limit:#{session_id}"
  177. if Rails.cache.read(rate_limit_key)
  178. render json: { error: 'Too many requests. Please try again later.' }, status: :too_many_requests
  179. return
  180. end
  181. # Set rate limit (5 requests per hour)
  182. Rails.cache.write(rate_limit_key, true, expires_in: 1.hour)
  183. end
  184. def get_or_create_session_id
  185. session_id = cookies[:analytics_session_id]
  186. if session_id.blank?
  187. session_id = generate_session_id
  188. cookies[:analytics_session_id] = {
  189. value: session_id,
  190. expires: 1.year.from_now,
  191. httponly: true,
  192. secure: Rails.env.production?,
  193. same_site: :lax
  194. }
  195. end
  196. session_id
  197. end
  198. def generate_session_id
  199. SecureRandom.hex(16)
  200. end
  201. def set_consent_cookies(consent_data)
  202. consent_data.each do |consent_type, granted|
  203. cookie_name = "analytics_consent_#{consent_type}"
  204. cookies[cookie_name] = {
  205. value: granted.to_s,
  206. expires: 1.year.from_now,
  207. httponly: true,
  208. secure: Rails.env.production?,
  209. same_site: :lax
  210. }
  211. end
  212. end
  213. end

app/controllers/graphql_controller.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class GraphqlController < ApplicationController
  3. # If accessing from outside this domain, nullify the session
  4. # This allows for outside API access while preventing CSRF attacks,
  5. # but you'll have to authenticate your user separately
  6. # protect_from_forgery with: :null_session
  7. def execute
  8. variables = prepare_variables(params[:variables])
  9. query = params[:query]
  10. operation_name = params[:operationName]
  11. context = {
  12. # Query context goes here, for example:
  13. # current_user: current_user,
  14. }
  15. result = RailspressSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
  16. render json: result
  17. rescue StandardError => e
  18. raise e unless Rails.env.development?
  19. handle_error_in_development(e)
  20. end
  21. private
  22. # Handle variables in form data, JSON body, or a blank value
  23. def prepare_variables(variables_param)
  24. case variables_param
  25. when String
  26. if variables_param.present?
  27. JSON.parse(variables_param) || {}
  28. else
  29. {}
  30. end
  31. when Hash
  32. variables_param
  33. when ActionController::Parameters
  34. variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
  35. when nil
  36. {}
  37. else
  38. raise ArgumentError, "Unexpected parameter: #{variables_param}"
  39. end
  40. end
  41. def handle_error_in_development(e)
  42. logger.error e.message
  43. logger.error e.backtrace.join("\n")
  44. render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500
  45. end
  46. end

app/controllers/home_controller.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class HomeController < ApplicationController
  2. def index
  3. # Get the active theme
  4. active_theme = Theme.active.first
  5. unless active_theme
  6. render html: "<h1>No active theme found</h1>", status: :internal_server_error
  7. return
  8. end
  9. # Ensure theme has a published version
  10. active_theme.ensure_published_version_exists!
  11. published_version = active_theme.published_version
  12. unless published_version
  13. render html: "<h1>Failed to create published theme version</h1>", status: :internal_server_error
  14. return
  15. end
  16. # Prepare context data
  17. featured_posts = Post.published.recent.limit(3).to_a
  18. recent_posts = Post.published.recent.limit(6).to_a
  19. categories = Term.for_taxonomy('category').limit(10).to_a
  20. context = {
  21. 'featured_posts' => featured_posts,
  22. 'recent_posts' => recent_posts,
  23. 'posts' => recent_posts, # Add posts for Nordic theme
  24. 'collections' => {
  25. 'posts' => recent_posts # Add collections.posts for Nordic theme
  26. },
  27. 'categories' => categories,
  28. 'template' => 'index',
  29. 'page' => {
  30. 'title' => SiteSetting.get('site_title', 'RailsPress'),
  31. 'seo_title' => SiteSetting.get('site_title', 'RailsPress'),
  32. 'url' => request.url,
  33. 'featured_image' => nil
  34. },
  35. 'site' => {
  36. 'title' => SiteSetting.get('site_title', 'RailsPress'),
  37. 'description' => SiteSetting.get('site_description', 'Built with RailsPress'),
  38. 'settings' => {
  39. 'comments_enabled' => SiteSetting.get('comments_enabled', true),
  40. 'comments_moderation' => SiteSetting.get('comments_moderation', true),
  41. 'comment_registration_required' => SiteSetting.get('comment_registration_required', false),
  42. 'close_comments_after_days' => SiteSetting.get('close_comments_after_days', 0),
  43. 'show_avatars' => SiteSetting.get('show_avatars', true),
  44. 'akismet_enabled' => SiteSetting.get('akismet_enabled', false),
  45. 'akismet_api_key' => SiteSetting.get('akismet_api_key', '')
  46. }
  47. },
  48. 'request' => {
  49. 'url' => request.url,
  50. 'params' => request.params
  51. },
  52. 'current_user' => user_signed_in? ? current_user : nil
  53. }
  54. # Use FrontendRendererService for proper rendering
  55. renderer = FrontendRendererService.new(published_version)
  56. begin
  57. # Check if user is logged in for admin bar
  58. show_admin_bar = user_signed_in?
  59. # Add admin bar to context if user is logged in
  60. if show_admin_bar
  61. context['show_admin_bar'] = true
  62. context['current_user'] = current_user
  63. end
  64. # Use FrontendRendererService to render the complete page
  65. html = renderer.render_template('index', context)
  66. assets = renderer.assets
  67. # Inject admin bar and assets into the rendered HTML
  68. if show_admin_bar
  69. # Add admin bar at the top
  70. admin_bar_html = render_to_string(partial: 'shared/admin_bar')
  71. # Add admin bar CSS
  72. admin_bar_css = <<~CSS
  73. <style>
  74. body { padding-top: 32px; } /* Make room for admin bar */
  75. </style>
  76. CSS
  77. # Inject admin bar CSS into head
  78. html = html.gsub(/<\/head>/i, "#{admin_bar_css}</head>")
  79. # Inject admin bar after opening body tag
  80. html = html.gsub(/<body[^>]*>/i) { |match| "#{match}\n#{admin_bar_html}" }
  81. end
  82. # Inject theme assets if not already present
  83. if assets[:css].present? && !html.include?('</style>')
  84. css_injection = "<style>#{assets[:css]}</style>"
  85. html = html.gsub(/<\/head>/i, "#{css_injection}</head>")
  86. end
  87. if assets[:js].present? && !html.include?('</script>')
  88. js_injection = "<script>#{assets[:js]}</script>"
  89. html = html.gsub(/<\/body>/i, "#{js_injection}</body>")
  90. end
  91. # Render the complete HTML directly
  92. render html: html.html_safe
  93. rescue => e
  94. Rails.logger.error "Homepage rendering failed: #{e.message}"
  95. render html: "<h1>Rendering Error: #{e.message}</h1>", status: :internal_server_error
  96. end
  97. end
  98. end

app/controllers/omniauth_callbacks_controller.rb

0.0% lines covered

100.0% branches covered

109 relevant lines. 0 lines covered and 109 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class OmniauthCallbacksController < Devise::OmniauthCallbacksController
  2. # Handle Google OAuth callback
  3. def google_oauth2
  4. handle_oauth_callback('google_oauth2')
  5. end
  6. # Handle GitHub OAuth callback
  7. def github
  8. handle_oauth_callback('github')
  9. end
  10. # Handle Facebook OAuth callback
  11. def facebook
  12. handle_oauth_callback('facebook')
  13. end
  14. # Handle Twitter OAuth callback
  15. def twitter
  16. handle_oauth_callback('twitter')
  17. end
  18. private
  19. def handle_oauth_callback(provider)
  20. auth_data = request.env['omniauth.auth']
  21. if auth_data.blank?
  22. # Determine redirect path based on request path
  23. redirect_path = request.path.include?('/admin/') ? new_admin_user_session_path : new_user_session_path
  24. redirect_to redirect_path, alert: 'Authentication failed. Please try again.'
  25. return
  26. end
  27. # Find or create user based on OAuth data
  28. user = find_or_create_user_from_oauth(auth_data, provider)
  29. if user.persisted?
  30. sign_in_and_redirect user, event: :authentication
  31. set_flash_message(:notice, :success, kind: provider.humanize) if is_navigational_format?
  32. else
  33. session["devise.#{provider}_data"] = auth_data.except('extra')
  34. # Determine redirect path based on request path
  35. redirect_path = request.path.include?('/admin/') ? new_admin_user_session_path : new_user_registration_path
  36. redirect_to redirect_path, alert: user.errors.full_messages.join(', ')
  37. end
  38. end
  39. def find_or_create_user_from_oauth(auth_data, provider)
  40. # Extract user information from OAuth data
  41. email = extract_email(auth_data)
  42. name = extract_name(auth_data)
  43. uid = auth_data.uid
  44. # Check if OAuth requires email and we don't have one
  45. if SiteSetting.get('oauth_require_email', true) && email.blank?
  46. return User.new.tap { |u| u.errors.add(:email, 'Email is required for OAuth authentication') }
  47. end
  48. # Try to find existing user by email first
  49. user = User.find_by(email: email) if email.present?
  50. if user
  51. # User exists - check if we should allow linking
  52. if SiteSetting.get('oauth_allow_existing_users', true)
  53. # Link OAuth account to existing user
  54. link_oauth_account(user, auth_data, provider)
  55. return user
  56. else
  57. return User.new.tap { |u| u.errors.add(:base, 'OAuth authentication not allowed for existing users') }
  58. end
  59. end
  60. # Try to find user by OAuth provider and UID
  61. oauth_account = OauthAccount.find_by(provider: provider, uid: uid)
  62. if oauth_account
  63. return oauth_account.user
  64. end
  65. # Create new user if auto-registration is enabled
  66. if SiteSetting.get('oauth_auto_register', true)
  67. create_user_from_oauth(auth_data, provider, email, name, uid)
  68. else
  69. User.new.tap { |u| u.errors.add(:base, 'Auto-registration is disabled') }
  70. end
  71. end
  72. def create_user_from_oauth(auth_data, provider, email, name, uid)
  73. # Generate a random password for OAuth users
  74. password = Devise.friendly_token[0, 20]
  75. user = User.new(
  76. email: email,
  77. name: name,
  78. password: password,
  79. password_confirmation: password,
  80. role: SiteSetting.get('oauth_default_role', 'subscriber')
  81. )
  82. if user.save
  83. # Create OAuth account record
  84. OauthAccount.create!(
  85. user: user,
  86. provider: provider,
  87. uid: uid,
  88. email: email,
  89. name: name,
  90. avatar_url: extract_avatar_url(auth_data)
  91. )
  92. user
  93. else
  94. user
  95. end
  96. end
  97. def link_oauth_account(user, auth_data, provider)
  98. uid = auth_data.uid
  99. email = extract_email(auth_data)
  100. name = extract_name(auth_data)
  101. # Check if OAuth account already exists
  102. oauth_account = OauthAccount.find_by(provider: provider, uid: uid)
  103. if oauth_account.nil?
  104. # Create new OAuth account link
  105. OauthAccount.create!(
  106. user: user,
  107. provider: provider,
  108. uid: uid,
  109. email: email,
  110. name: name,
  111. avatar_url: extract_avatar_url(auth_data)
  112. )
  113. end
  114. end
  115. def extract_email(auth_data)
  116. auth_data.info.email.presence
  117. end
  118. def extract_name(auth_data)
  119. name = auth_data.info.name.presence
  120. name ||= "#{auth_data.info.first_name} #{auth_data.info.last_name}".strip.presence
  121. name ||= auth_data.info.nickname.presence
  122. name ||= 'OAuth User'
  123. end
  124. def extract_avatar_url(auth_data)
  125. auth_data.info.image.presence
  126. end
  127. end

app/controllers/pages_controller.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PagesController < ApplicationController
  2. include LiquidRenderable
  3. def show
  4. path = params[:path]
  5. page = Page.friendly.find(path.split('/').last)
  6. # Check if visible (handles all statuses)
  7. unless page.visible_to_public? || can_view_page?(page)
  8. raise ActiveRecord::RecordNotFound
  9. end
  10. # Auto-publish scheduled pages
  11. page.check_scheduled_publish
  12. # Check password protection
  13. if page.password_protected? && !password_verified?(page)
  14. return render_liquid('password_protected', { 'page' => page })
  15. end
  16. comments = page.comments.approved.root_comments.order(created_at: :desc)
  17. # Determine template (check for specialized template like page.about)
  18. # Use page slug to determine if there's a specialized template
  19. custom_template_path = Rails.root.join('app', 'themes', current_theme_name, 'templates', "page.#{page.slug}.json")
  20. template_name = File.exist?(custom_template_path) ? "page.#{page.slug}" : 'page'
  21. render_liquid(template_name, {
  22. 'page' => {
  23. 'title' => page.title,
  24. 'content' => page.content.to_s,
  25. 'description' => page.respond_to?(:excerpt) ? page.excerpt : page.content.to_s.truncate(200),
  26. 'featured_image' => page.respond_to?(:featured_image_url) ? page.featured_image_url : nil,
  27. 'slug' => page.slug,
  28. 'author' => page.user,
  29. 'published_at' => page.published_at,
  30. 'updated_at' => page.updated_at
  31. },
  32. 'comments' => comments,
  33. 'template' => 'page'
  34. })
  35. rescue ActiveRecord::RecordNotFound
  36. render_liquid_error(404)
  37. end
  38. # POST /pages/:path/verify_password
  39. def verify_password
  40. path = params[:path]
  41. @page = Page.friendly.find(path.split('/').last)
  42. if @page.password_matches?(params[:password])
  43. # Store verified page ID in session
  44. session[:verified_pages] ||= []
  45. session[:verified_pages] << @page.id unless session[:verified_pages].include?(@page.id)
  46. redirect_to page_path(@page.slug), notice: 'Password verified successfully.'
  47. else
  48. redirect_to page_path(@page.slug), alert: 'Incorrect password. Please try again.'
  49. end
  50. end
  51. private
  52. def can_view_page?(page)
  53. return false unless user_signed_in?
  54. # Admins and editors can view everything
  55. return true if current_user.administrator? || current_user.editor?
  56. # Authors can view their own pages
  57. return true if page.user_id == current_user.id
  58. # Private pages visible to any logged-in user
  59. return true if page.private_page_status?
  60. false
  61. end
  62. def password_verified?(page)
  63. return true unless page.password_protected?
  64. return true if can_view_page?(page) # Admins/editors/authors bypass password
  65. session[:verified_pages]&.include?(page.id)
  66. end
  67. end

app/controllers/plugins/slick_forms/forms_controller.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms Public Forms Controller
  2. # Handles public form display and submission
  3. class Plugins::SlickForms::FormsController < ApplicationController
  4. before_action :set_form, only: [:show, :embed]
  5. def show
  6. # Display public form
  7. render layout: 'application'
  8. end
  9. def embed
  10. # Display form for embedding in other sites
  11. render layout: false
  12. end
  13. private
  14. def set_form
  15. @form = get_form_by_id(params[:form_id])
  16. redirect_to root_path, alert: 'Form not found.' unless @form
  17. end
  18. def get_form_by_id(id)
  19. return nil unless table_exists?('slick_forms')
  20. result = ActiveRecord::Base.connection.execute(
  21. "SELECT * FROM slick_forms WHERE id = #{id} AND active = 1"
  22. ).first
  23. result&.symbolize_keys
  24. end
  25. def table_exists?(table_name)
  26. ActiveRecord::Base.connection.table_exists?(table_name)
  27. end
  28. end

app/controllers/plugins/slick_forms/submissions_controller.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms Public Submissions Controller
  2. # Handles public form submissions
  3. class Plugins::SlickForms::SubmissionsController < ApplicationController
  4. protect_from_forgery with: :null_session
  5. def create
  6. form_id = params[:form_id]
  7. form_data = submission_params
  8. # Get the form to validate
  9. @form = get_form_by_id(form_id)
  10. unless @form
  11. render json: { error: 'Form not found' }, status: :not_found
  12. return
  13. end
  14. # Process the submission
  15. if process_submission(form_id, form_data)
  16. render json: {
  17. success: true,
  18. message: 'Thank you for your submission!',
  19. redirect_url: @form[:settings]['success_redirect_url']
  20. }
  21. else
  22. render json: {
  23. success: false,
  24. error: 'Failed to process submission'
  25. }, status: :unprocessable_entity
  26. end
  27. end
  28. private
  29. def submission_params
  30. params.except(:controller, :action, :form_id).permit!
  31. end
  32. def get_form_by_id(id)
  33. return nil unless table_exists?('slick_forms')
  34. result = ActiveRecord::Base.connection.execute(
  35. "SELECT * FROM slick_forms WHERE id = #{id} AND active = 1"
  36. ).first
  37. result&.symbolize_keys
  38. end
  39. def process_submission(form_id, data)
  40. # Get the plugin instance to use its processing logic
  41. plugin = Railspress::PluginSystem.get_plugin('slick_forms')
  42. if plugin
  43. # Use plugin's spam protection and validation
  44. return false if plugin.send(:detect_spam, data)
  45. return false unless plugin.send(:validate_unique_entries, data)
  46. end
  47. # Save submission to database
  48. save_submission(form_id, data)
  49. # Send email notification if configured
  50. send_notification(form_id, data) if should_send_notification?
  51. true
  52. rescue => e
  53. Rails.logger.error "Failed to process submission: #{e.message}"
  54. false
  55. end
  56. def save_submission(form_id, data)
  57. return false unless table_exists?('slick_form_submissions')
  58. ActiveRecord::Base.connection.execute(
  59. "INSERT INTO slick_form_submissions (slick_form_id, data, ip_address, user_agent, referrer, spam, created_at, updated_at) VALUES (#{form_id}, '#{data.to_json}', '#{request.remote_ip}', '#{request.user_agent}', '#{request.referer}', 0, NOW(), NOW())"
  60. )
  61. # Update form submission count
  62. ActiveRecord::Base.connection.execute(
  63. "UPDATE slick_forms SET submissions_count = submissions_count + 1 WHERE id = #{form_id}"
  64. ) if table_exists?('slick_forms')
  65. true
  66. end
  67. def send_notification(form_id, data)
  68. # This would integrate with the plugin's notification system
  69. Rails.logger.info "Sending notification for form #{form_id}"
  70. end
  71. def should_send_notification?
  72. # Check plugin settings for email notifications
  73. plugin = Railspress::PluginSystem.get_plugin('slick_forms')
  74. return false unless plugin
  75. plugin.get_setting(:enable_notifications, true)
  76. end
  77. def table_exists?(table_name)
  78. ActiveRecord::Base.connection.table_exists?(table_name)
  79. end
  80. end

app/controllers/posts_controller.rb

0.0% lines covered

100.0% branches covered

185 relevant lines. 0 lines covered and 185 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PostsController < ApplicationController
  2. include LiquidRenderable
  3. def index
  4. posts = Post.visible_to_public.recent.includes(:user, :categories, :tags).page(params[:page])
  5. title = "Blog"
  6. render_liquid('blog', {
  7. 'posts' => posts,
  8. 'title' => title,
  9. 'template' => 'blog',
  10. 'paginate' => {
  11. 'current_page' => posts.current_page,
  12. 'total_pages' => posts.total_pages,
  13. 'per_page' => posts.limit_value
  14. }
  15. })
  16. end
  17. def show
  18. post = Post.friendly.find(params[:id])
  19. # Check if visible (handles all statuses)
  20. unless post.visible_to_public? || can_view_post?(post)
  21. raise ActiveRecord::RecordNotFound
  22. end
  23. # Auto-publish scheduled posts
  24. post.check_scheduled_publish
  25. # Check password protection
  26. if post.password_protected? && !password_verified?(post)
  27. return render_liquid('password_protected', { 'post' => post })
  28. end
  29. # Use Related Posts plugin if available, otherwise fallback to basic logic
  30. category_taxonomy = Taxonomy.find_by(slug: 'category')
  31. post_categories = category_taxonomy ? post.terms.where(taxonomy: category_taxonomy) : []
  32. related_posts = if defined?(RelatedPosts)
  33. RelatedPosts.find_related(post, 3)
  34. elsif post_categories.any?
  35. term_ids = post_categories.pluck(:id)
  36. Post.visible_to_public
  37. .joins(:term_relationships)
  38. .where(term_relationships: { term_id: term_ids })
  39. .where.not(id: post.id)
  40. .distinct
  41. .limit(3)
  42. else
  43. []
  44. end
  45. comments = post.comments.approved.root_comments.order(created_at: :desc)
  46. # Trigger post viewed hook for analytics plugins
  47. Railspress::PluginSystem.do_action('post_viewed', post.id) if defined?(Railspress::PluginSystem)
  48. render_liquid('post', {
  49. 'post' => post,
  50. 'page' => {
  51. 'title' => post.title,
  52. 'description' => post.respond_to?(:excerpt) ? post.excerpt : post.content.to_s.truncate(200),
  53. 'featured_image' => post.respond_to?(:featured_image_url) ? post.featured_image_url : nil,
  54. 'type' => 'article',
  55. 'schema_type' => 'Article',
  56. 'author' => post.user,
  57. 'published_at' => post.published_at,
  58. 'updated_at' => post.updated_at,
  59. 'categories' => post.terms.joins(:taxonomy).where(taxonomies: { slug: 'category' }).to_a,
  60. 'tags' => post.terms.joins(:taxonomy).where(taxonomies: { slug: 'tag' }).to_a
  61. },
  62. 'site' => {
  63. 'title' => SiteSetting.get('site_title', 'RailsPress'),
  64. 'description' => SiteSetting.get('site_description', 'Built with RailsPress'),
  65. 'settings' => {
  66. 'comments_enabled' => SiteSetting.get('comments_enabled', true),
  67. 'comments_moderation' => SiteSetting.get('comments_moderation', true),
  68. 'comment_registration_required' => SiteSetting.get('comment_registration_required', false),
  69. 'close_comments_after_days' => SiteSetting.get('close_comments_after_days', 0),
  70. 'show_avatars' => SiteSetting.get('show_avatars', true),
  71. 'akismet_enabled' => SiteSetting.get('akismet_enabled', false),
  72. 'akismet_api_key' => SiteSetting.get('akismet_api_key', '')
  73. }
  74. },
  75. 'related_posts' => related_posts.to_a,
  76. 'comments' => comments.to_a,
  77. 'current_user' => user_signed_in? ? current_user : nil,
  78. 'template' => 'post'
  79. })
  80. end
  81. # POST /blog/:id/verify_password
  82. def verify_password
  83. @post = Post.friendly.find(params[:id])
  84. if @post.password_matches?(params[:password])
  85. # Store verified post ID in session
  86. session[:verified_posts] ||= []
  87. session[:verified_posts] << @post.id unless session[:verified_posts].include?(@post.id)
  88. redirect_to blog_post_path(@post), notice: 'Password verified successfully.'
  89. else
  90. redirect_to blog_post_path(@post), alert: 'Incorrect password. Please try again.'
  91. end
  92. end
  93. def category
  94. category = Term.for_taxonomy('category').friendly.find(params[:slug])
  95. posts = category.posts.visible_to_public.recent.page(params[:page])
  96. render_liquid('category', {
  97. 'category' => category,
  98. 'posts' => posts,
  99. 'title' => "Category: #{category.name}",
  100. 'page' => {
  101. 'title' => "Category: #{category.name}",
  102. 'description' => category.description
  103. },
  104. 'template' => 'category',
  105. 'paginate' => {
  106. 'current_page' => posts.current_page,
  107. 'total_pages' => posts.total_pages,
  108. 'per_page' => posts.limit_value
  109. }
  110. })
  111. end
  112. def tag
  113. tag = Term.for_taxonomy('post_tag').friendly.find(params[:slug])
  114. posts = tag.posts.visible_to_public.recent.page(params[:page])
  115. render_liquid('tag', {
  116. 'tag' => tag,
  117. 'posts' => posts,
  118. 'title' => "Tag: #{tag.name}",
  119. 'page' => {
  120. 'title' => "Tag: #{tag.name}",
  121. 'description' => tag.description
  122. },
  123. 'template' => 'tag',
  124. 'paginate' => {
  125. 'current_page' => posts.current_page,
  126. 'total_pages' => posts.total_pages,
  127. 'per_page' => posts.limit_value
  128. }
  129. })
  130. end
  131. def archive
  132. year = params[:year].to_i
  133. month = params[:month]&.to_i
  134. posts = Post.visible_to_public
  135. # SQLite-compatible date filtering
  136. if month
  137. start_date = Date.new(year, month, 1)
  138. end_date = start_date.end_of_month
  139. else
  140. start_date = Date.new(year, 1, 1)
  141. end_date = Date.new(year, 12, 31)
  142. end
  143. posts = posts.where(published_at: start_date.beginning_of_day..end_date.end_of_day)
  144. posts = posts.recent.page(params[:page])
  145. title = month ? "Archive: #{Date::MONTHNAMES[month]} #{year}" : "Archive: #{year}"
  146. render_liquid('archive', {
  147. 'posts' => posts,
  148. 'title' => title,
  149. 'year' => year,
  150. 'month' => month,
  151. 'page' => {
  152. 'title' => title
  153. },
  154. 'template' => 'archive',
  155. 'paginate' => {
  156. 'current_page' => posts.current_page,
  157. 'total_pages' => posts.total_pages,
  158. 'per_page' => posts.limit_value
  159. }
  160. })
  161. end
  162. def search
  163. query = params[:q]
  164. posts = query.present? ? Post.visible_to_public.search(query).recent.page(params[:page]) : Post.none
  165. render_liquid('search', {
  166. 'posts' => posts,
  167. 'query' => query,
  168. 'title' => "Search results for: #{query}",
  169. 'page' => {
  170. 'title' => "Search results for: #{query}"
  171. },
  172. 'template' => 'search',
  173. 'paginate' => posts.any? ? {
  174. 'current_page' => posts.current_page,
  175. 'total_pages' => posts.total_pages,
  176. 'per_page' => posts.limit_value
  177. } : {}
  178. })
  179. end
  180. private
  181. def can_view_post?(post)
  182. return false unless user_signed_in?
  183. # Admins and editors can view everything
  184. return true if current_user.administrator? || current_user.editor?
  185. # Authors can view their own posts
  186. return true if post.user_id == current_user.id
  187. # Private posts visible to any logged-in user
  188. return true if post.private_post_status?
  189. false
  190. end
  191. def password_verified?(post)
  192. return true unless post.password_protected?
  193. return true if can_view_post?(post) # Admins/editors/authors bypass password
  194. session[:verified_posts]&.include?(post.id)
  195. end
  196. end

app/controllers/preview_controller.rb

0.0% lines covered

100.0% branches covered

37 relevant lines. 0 lines covered and 37 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PreviewController < ApplicationController
  2. skip_before_action :verify_authenticity_token
  3. before_action :set_published_version
  4. before_action :set_renderer
  5. # GET /preview/:template_name
  6. def show
  7. template_name = params[:template_name] || 'index'
  8. begin
  9. @preview_html = @renderer.render_template(template_name, preview_context)
  10. @assets = @renderer.assets
  11. render layout: 'preview'
  12. rescue => e
  13. Rails.logger.error "Preview rendering failed: #{e.message}"
  14. @preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
  15. @assets = { css: '', js: '' }
  16. render layout: 'preview'
  17. end
  18. end
  19. private
  20. def set_published_version
  21. # Get the latest PublishedThemeVersion for the active theme
  22. @active_theme = Theme.active_theme
  23. return render_404 unless @active_theme
  24. @published_version = PublishedThemeVersion.where(theme_name: @active_theme.name.underscore).latest.first
  25. return render_404 unless @published_version
  26. end
  27. def set_renderer
  28. @renderer = FrontendRendererService.new(@published_version)
  29. end
  30. def preview_context
  31. {
  32. current_user: current_user,
  33. request: request
  34. }
  35. end
  36. def render_404
  37. render plain: 'Theme not found', status: :not_found
  38. end
  39. end

app/controllers/slick_forms_controller.rb

0.0% lines covered

100.0% branches covered

248 relevant lines. 0 lines covered and 248 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Fluent Forms Controller
  2. # Handles form submissions and frontend form rendering
  3. class FluentFormsController < ApplicationController
  4. skip_before_action :verify_authenticity_token, only: [:submit], if: -> { request.format.json? }
  5. before_action :set_form, only: [:show, :submit]
  6. # GET /fluent-forms/:id
  7. def show
  8. render plain: render_form(@form)
  9. end
  10. # POST /fluent-forms/submit
  11. def submit
  12. unless @form
  13. return respond_with_error('Form not found', 404)
  14. end
  15. # Check if form is active
  16. if @form[:status] != 'published'
  17. return respond_with_error('Form is not available', 403)
  18. end
  19. # Validate form data
  20. validation_result = validate_submission
  21. unless validation_result[:valid]
  22. return respond_with_error(validation_result[:errors].join(', '), 422)
  23. end
  24. # Check spam protection
  25. if spam_detected?
  26. log_spam_attempt
  27. return respond_with_success('Thank you for your submission!')
  28. end
  29. # Create submission
  30. submission_data = prepare_submission_data
  31. submission_id = FluentFormsPro.create_submission(
  32. @form[:id],
  33. submission_data,
  34. current_user&.id
  35. )
  36. if submission_id
  37. # Store entry details
  38. store_entry_details(submission_id)
  39. # Handle file uploads
  40. handle_file_uploads(submission_id) if has_file_uploads?
  41. # Process payment if required
  42. if @form[:has_payment]
  43. payment_result = process_payment(submission_id)
  44. return respond_with_error(payment_result[:error], 422) unless payment_result[:success]
  45. end
  46. respond_with_success(
  47. @form[:settings][:confirmation][:messageToShow] || 'Thank you for your submission!',
  48. submission_id
  49. )
  50. else
  51. respond_with_error('Failed to save submission', 500)
  52. end
  53. rescue => e
  54. Rails.logger.error "[Fluent Forms] Submission error: #{e.message}"
  55. Rails.logger.error e.backtrace.join("\n")
  56. respond_with_error('An error occurred while processing your submission', 500)
  57. end
  58. private
  59. def set_form
  60. form_id = params[:id] || params[:form_id]
  61. @form = get_form_data(form_id) if form_id
  62. end
  63. def get_form_data(form_id)
  64. plugin = FluentFormsPro.new
  65. plugin.get_form(form_id)
  66. end
  67. def render_form(form)
  68. FluentFormsRenderer.new(form).render
  69. end
  70. def validate_submission
  71. errors = []
  72. fields = @form[:form_fields][:fields] || []
  73. fields.each do |field|
  74. field_name = field.dig(:attributes, :name)
  75. next unless field_name
  76. validation_rules = field.dig(:settings, :validation_rules) || {}
  77. field_value = params[field_name]
  78. # Check required fields
  79. if validation_rules.dig(:required, :value)
  80. if field_value.blank?
  81. message = validation_rules.dig(:required, :message) || "#{field.dig(:settings, :label)} is required"
  82. errors << message
  83. end
  84. end
  85. # Email validation
  86. if validation_rules.dig(:email, :value) && field_value.present?
  87. unless valid_email?(field_value)
  88. message = validation_rules.dig(:email, :message) || 'Please enter a valid email'
  89. errors << message
  90. end
  91. end
  92. # Min/Max length
  93. if field_value.present?
  94. min_length = validation_rules.dig(:min_length, :value)
  95. max_length = validation_rules.dig(:max_length, :value)
  96. if min_length && field_value.length < min_length.to_i
  97. errors << validation_rules.dig(:min_length, :message) || "Minimum length is #{min_length}"
  98. end
  99. if max_length && field_value.length > max_length.to_i
  100. errors << validation_rules.dig(:max_length, :message) || "Maximum length is #{max_length}"
  101. end
  102. end
  103. end
  104. {
  105. valid: errors.empty?,
  106. errors: errors
  107. }
  108. end
  109. def valid_email?(email)
  110. email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
  111. end
  112. def spam_detected?
  113. plugin = FluentFormsPro.new
  114. # Honeypot check
  115. if plugin.setting_enabled?('honeypot_enabled')
  116. return true if params[:_ff_honeypot].present?
  117. end
  118. # reCAPTCHA check
  119. if plugin.setting_enabled?('recaptcha_enabled')
  120. recaptcha_token = params[:recaptcha_token]
  121. return true unless verify_recaptcha(recaptcha_token)
  122. end
  123. false
  124. end
  125. def verify_recaptcha(token)
  126. return true unless token
  127. plugin = FluentFormsPro.new
  128. secret_key = plugin.get_setting('recaptcha_secret_key')
  129. return true if secret_key.blank?
  130. # Verify with Google reCAPTCHA API
  131. uri = URI('https://www.google.com/recaptcha/api/siteverify')
  132. response = Net::HTTP.post_form(uri, {
  133. secret: secret_key,
  134. response: token,
  135. remoteip: request.remote_ip
  136. })
  137. result = JSON.parse(response.body)
  138. result['success'] == true
  139. rescue => e
  140. Rails.logger.error "[Fluent Forms] reCAPTCHA verification error: #{e.message}"
  141. true # Allow submission on verification error
  142. end
  143. def log_spam_attempt
  144. ActiveRecord::Base.connection.execute(
  145. "INSERT INTO ff_logs (form_id, log_type, title, description, created_at)
  146. VALUES (?, ?, ?, ?, ?)",
  147. @form[:id],
  148. 'spam',
  149. 'Spam submission blocked',
  150. "IP: #{request.remote_ip}",
  151. Time.current
  152. )
  153. end
  154. def prepare_submission_data
  155. {
  156. response_data: collect_form_data,
  157. source_url: request.referrer || request.original_url,
  158. browser: request.user_agent,
  159. device: detect_device,
  160. ip_address: get_ip_address
  161. }
  162. end
  163. def collect_form_data
  164. data = {}
  165. fields = @form[:form_fields][:fields] || []
  166. fields.each do |field|
  167. field_name = field.dig(:attributes, :name)
  168. data[field_name] = params[field_name] if field_name && params.key?(field_name)
  169. end
  170. data
  171. end
  172. def detect_device
  173. user_agent = request.user_agent.to_s.downcase
  174. return 'mobile' if user_agent.include?('mobile')
  175. return 'tablet' if user_agent.include?('tablet') || user_agent.include?('ipad')
  176. 'desktop'
  177. end
  178. def get_ip_address
  179. plugin = FluentFormsPro.new
  180. return nil if plugin.setting_enabled?('disable_ip_logging')
  181. request.remote_ip
  182. end
  183. def store_entry_details(submission_id)
  184. fields = @form[:form_fields][:fields] || []
  185. fields.each do |field|
  186. field_name = field.dig(:attributes, :name)
  187. next unless field_name && params.key?(field_name)
  188. ActiveRecord::Base.connection.execute(
  189. "INSERT INTO ff_entry_details (submission_id, form_id, field_name, field_value, created_at, updated_at)
  190. VALUES (?, ?, ?, ?, ?, ?)",
  191. submission_id,
  192. @form[:id],
  193. field_name,
  194. params[field_name].to_s,
  195. Time.current,
  196. Time.current
  197. )
  198. end
  199. end
  200. def has_file_uploads?
  201. params[:_files].present? || params.values.any? { |v| v.is_a?(ActionDispatch::Http::UploadedFile) }
  202. end
  203. def handle_file_uploads(submission_id)
  204. plugin = FluentFormsPro.new
  205. upload_folder = plugin.get_setting('upload_folder', 'form-uploads')
  206. max_size = plugin.get_setting('max_file_size', 10).to_i * 1024 * 1024 # Convert MB to bytes
  207. allowed_types = plugin.get_setting('allowed_file_types', '').split(',').map(&:strip)
  208. params.each do |key, value|
  209. next unless value.is_a?(ActionDispatch::Http::UploadedFile)
  210. # Validate file size
  211. if value.size > max_size
  212. next
  213. end
  214. # Validate file type
  215. extension = File.extname(value.original_filename).delete('.').downcase
  216. next unless allowed_types.include?(extension)
  217. # Save file
  218. upload_path = Rails.root.join('public', 'uploads', upload_folder, submission_id.to_s)
  219. FileUtils.mkdir_p(upload_path)
  220. filename = "#{Time.current.to_i}_#{value.original_filename}"
  221. filepath = upload_path.join(filename)
  222. File.open(filepath, 'wb') do |file|
  223. file.write(value.read)
  224. end
  225. # Update entry detail with file path
  226. file_url = "/uploads/#{upload_folder}/#{submission_id}/#{filename}"
  227. ActiveRecord::Base.connection.execute(
  228. "UPDATE ff_entry_details SET field_value = ?
  229. WHERE submission_id = ? AND field_name = ?",
  230. file_url,
  231. submission_id,
  232. key
  233. )
  234. end
  235. rescue => e
  236. Rails.logger.error "[Fluent Forms] File upload error: #{e.message}"
  237. end
  238. def process_payment(submission_id)
  239. # Payment processing would be implemented here
  240. # Integrate with Stripe, PayPal, etc.
  241. {
  242. success: true,
  243. transaction_id: SecureRandom.hex(16)
  244. }
  245. end
  246. def respond_with_success(message, submission_id = nil)
  247. response = {
  248. success: true,
  249. message: message
  250. }
  251. response[:submission_id] = submission_id if submission_id
  252. if request.format.json?
  253. render json: response
  254. else
  255. flash[:success] = message
  256. redirect_back fallback_location: root_path
  257. end
  258. end
  259. def respond_with_error(message, status = 422)
  260. response = {
  261. success: false,
  262. message: message
  263. }
  264. if request.format.json?
  265. render json: response, status: status
  266. else
  267. flash[:error] = message
  268. redirect_back fallback_location: root_path
  269. end
  270. end
  271. end

app/controllers/subscribers_controller.rb

0.0% lines covered

100.0% branches covered

54 relevant lines. 0 lines covered and 54 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class SubscribersController < ApplicationController
  2. # POST /subscribe
  3. def create
  4. @subscriber = Subscriber.new(subscriber_params)
  5. @subscriber.status = 'pending'
  6. @subscriber.source = params[:source] || 'website'
  7. @subscriber.ip_address = request.remote_ip
  8. @subscriber.user_agent = request.user_agent
  9. if @subscriber.save
  10. respond_to do |format|
  11. format.html do
  12. redirect_back fallback_location: root_path, notice: 'Successfully subscribed! Please check your email to confirm.'
  13. end
  14. format.json do
  15. render json: { success: true, message: 'Successfully subscribed' }, status: :created
  16. end
  17. end
  18. else
  19. respond_to do |format|
  20. format.html do
  21. redirect_back fallback_location: root_path, alert: @subscriber.errors.full_messages.join(', ')
  22. end
  23. format.json do
  24. render json: { success: false, errors: @subscriber.errors.full_messages }, status: :unprocessable_entity
  25. end
  26. end
  27. end
  28. end
  29. # GET /unsubscribe/:token
  30. def unsubscribe
  31. @subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
  32. unless @subscriber
  33. redirect_to root_path, alert: 'Invalid unsubscribe link'
  34. return
  35. end
  36. @subscriber.unsubscribe!
  37. render :unsubscribe
  38. end
  39. # GET /confirm/:token
  40. def confirm
  41. @subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
  42. unless @subscriber
  43. redirect_to root_path, alert: 'Invalid confirmation link'
  44. return
  45. end
  46. if @subscriber.confirmed_status?
  47. @already_confirmed = true
  48. else
  49. @subscriber.confirm!
  50. end
  51. render :confirm
  52. end
  53. private
  54. def subscriber_params
  55. params.require(:subscriber).permit(:email, :name)
  56. end
  57. end

app/controllers/theme_assets_controller.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeAssetsController < ApplicationController
  2. skip_before_action :verify_authenticity_token
  3. def show
  4. theme_name = params[:theme]
  5. asset_path_array = params[:path]
  6. # Security: only allow alphanumeric, hyphens, underscores, and dots in theme name
  7. unless theme_name.match?(/\A[a-zA-Z0-9_-]+\z/)
  8. return head :not_found
  9. end
  10. # Construct the full path to the asset
  11. full_path = Rails.root.join('app', 'themes', theme_name, 'assets', *asset_path_array)
  12. # Security: ensure the path is within the theme directory
  13. assets_dir = Rails.root.join('app', 'themes', theme_name, 'assets')
  14. unless full_path.to_s.start_with?(assets_dir.to_s)
  15. return head :forbidden
  16. end
  17. # Check if file exists
  18. unless File.exist?(full_path) && File.file?(full_path)
  19. Rails.logger.warn "Theme asset not found: #{full_path}"
  20. return head :not_found
  21. end
  22. # Determine content type
  23. extension = File.extname(full_path)[1..-1]
  24. content_type = Mime::Type.lookup_by_extension(extension)
  25. content_type ||= 'application/octet-stream'
  26. # Send file with caching headers
  27. expires_in 1.year, public: true
  28. send_file full_path, type: content_type.to_s, disposition: 'inline'
  29. end
  30. end

app/controllers/themes_controller.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemesController < ApplicationController
  2. # GET /themes/preview?theme=theme_name (public preview)
  3. def preview
  4. @theme_id = params[:id]
  5. @theme = Theme.find(@theme_id)
  6. @theme_name = @theme.name
  7. @theme_config = load_theme_config(@theme_name)
  8. # Ensure theme has a published version
  9. @theme.ensure_published_version_exists!
  10. published_version = @theme.published_version
  11. # If still no published version, create one
  12. unless published_version
  13. @theme.ensure_published_version_exists!
  14. published_version = @theme.published_version
  15. end
  16. if published_version
  17. # Use FrontendRendererService for proper rendering
  18. renderer = FrontendRendererService.new(published_version)
  19. template_type = params[:template] || 'index'
  20. begin
  21. @preview_html = renderer.render_template(template_type, preview_context)
  22. @assets = renderer.assets
  23. rescue => e
  24. Rails.logger.error "Theme preview rendering failed: #{e.message}"
  25. @preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
  26. @assets = { css: '', js: '' }
  27. end
  28. else
  29. @preview_html = "<div style='padding: 20px; color: red;'>No published version found for #{@theme_name}</div>"
  30. @assets = { css: '', js: '' }
  31. end
  32. # Render homepage with preview theme
  33. @featured_posts = Post.published.order(published_at: :desc).limit(3)
  34. @recent_posts = Post.published.order(published_at: :desc).limit(6)
  35. @categories = Term.for_taxonomy('category').root_terms.limit(5)
  36. render 'preview', layout: false
  37. end
  38. # POST /themes/switch (if you want public theme switching)
  39. def switch
  40. theme_name = params[:theme]
  41. # Only allow admins to switch themes
  42. unless current_user&.administrator?
  43. redirect_back fallback_location: root_path, alert: 'Permission denied'
  44. return
  45. end
  46. if Railspress::ThemeLoader.activate_theme(theme_name)
  47. redirect_back fallback_location: root_path, notice: "Theme switched to #{theme_name.titleize}"
  48. else
  49. redirect_back fallback_location: root_path, alert: "Failed to switch theme"
  50. end
  51. end
  52. private
  53. def load_theme_config(theme_name)
  54. config_path = Rails.root.join('app', 'themes', theme_name, 'config.yml')
  55. File.exist?(config_path) ? YAML.load_file(config_path) : {}
  56. end
  57. def preview_context
  58. {
  59. featured_posts: @featured_posts,
  60. recent_posts: @recent_posts,
  61. categories: @categories
  62. }
  63. end
  64. end

app/controllers/users/confirmations_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::ConfirmationsController < Devise::ConfirmationsController
  3. # GET /resource/confirmation/new
  4. # def new
  5. # super
  6. # end
  7. # POST /resource/confirmation
  8. # def create
  9. # super
  10. # end
  11. # GET /resource/confirmation?confirmation_token=abcdef
  12. # def show
  13. # super
  14. # end
  15. # protected
  16. # The path used after resending confirmation instructions.
  17. # def after_resending_confirmation_instructions_path_for(resource_name)
  18. # super(resource_name)
  19. # end
  20. # The path used after confirmation.
  21. # def after_confirmation_path_for(resource_name, resource)
  22. # super(resource_name, resource)
  23. # end
  24. end

app/controllers/users/omniauth_callbacks_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  3. # You should configure your model like this:
  4. # devise :omniauthable, omniauth_providers: [:twitter]
  5. # You should also create an action method in this controller like this:
  6. # def twitter
  7. # end
  8. # More info at:
  9. # https://github.com/heartcombo/devise#omniauth
  10. # GET|POST /resource/auth/twitter
  11. # def passthru
  12. # super
  13. # end
  14. # GET|POST /users/auth/twitter/callback
  15. # def failure
  16. # super
  17. # end
  18. # protected
  19. # The path used when OmniAuth fails
  20. # def after_omniauth_failure_path_for(scope)
  21. # super(scope)
  22. # end
  23. end

app/controllers/users/passwords_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::PasswordsController < Devise::PasswordsController
  3. # GET /resource/password/new
  4. # def new
  5. # super
  6. # end
  7. # POST /resource/password
  8. # def create
  9. # super
  10. # end
  11. # GET /resource/password/edit?reset_password_token=abcdef
  12. # def edit
  13. # super
  14. # end
  15. # PUT /resource/password
  16. # def update
  17. # super
  18. # end
  19. # protected
  20. # def after_resetting_password_path_for(resource)
  21. # super(resource)
  22. # end
  23. # The path used after sending reset password instructions
  24. # def after_sending_reset_password_instructions_path_for(resource_name)
  25. # super(resource_name)
  26. # end
  27. end

app/controllers/users/registrations_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::RegistrationsController < Devise::RegistrationsController
  3. # before_action :configure_sign_up_params, only: [:create]
  4. # before_action :configure_account_update_params, only: [:update]
  5. # GET /resource/sign_up
  6. # def new
  7. # super
  8. # end
  9. # POST /resource
  10. # def create
  11. # super
  12. # end
  13. # GET /resource/edit
  14. # def edit
  15. # super
  16. # end
  17. # PUT /resource
  18. # def update
  19. # super
  20. # end
  21. # DELETE /resource
  22. # def destroy
  23. # super
  24. # end
  25. # GET /resource/cancel
  26. # Forces the session data which is usually expired after sign
  27. # in to be expired now. This is useful if the user wants to
  28. # cancel oauth signing in/up in the middle of the process,
  29. # removing all OAuth session data.
  30. # def cancel
  31. # super
  32. # end
  33. # protected
  34. # If you have extra params to permit, append them to the sanitizer.
  35. # def configure_sign_up_params
  36. # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute])
  37. # end
  38. # If you have extra params to permit, append them to the sanitizer.
  39. # def configure_account_update_params
  40. # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
  41. # end
  42. # The path used after sign up.
  43. # def after_sign_up_path_for(resource)
  44. # super(resource)
  45. # end
  46. # The path used after sign up for inactive accounts.
  47. # def after_inactive_sign_up_path_for(resource)
  48. # super(resource)
  49. # end
  50. end

app/controllers/users/sessions_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::SessionsController < Devise::SessionsController
  3. # before_action :configure_sign_in_params, only: [:create]
  4. # GET /resource/sign_in
  5. # def new
  6. # super
  7. # end
  8. # POST /resource/sign_in
  9. # def create
  10. # super
  11. # end
  12. # DELETE /resource/sign_out
  13. # def destroy
  14. # super
  15. # end
  16. # protected
  17. # If you have extra params to permit, append them to the sanitizer.
  18. # def configure_sign_in_params
  19. # devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
  20. # end
  21. end

app/controllers/users/unlocks_controller.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class Users::UnlocksController < Devise::UnlocksController
  3. # GET /resource/unlock/new
  4. # def new
  5. # super
  6. # end
  7. # POST /resource/unlock
  8. # def create
  9. # super
  10. # end
  11. # GET /resource/unlock?unlock_token=abcdef
  12. # def show
  13. # super
  14. # end
  15. # protected
  16. # The path used after sending unlock password instructions
  17. # def after_sending_unlock_instructions_path_for(resource)
  18. # super(resource)
  19. # end
  20. # The path used after unlocking the resource
  21. # def after_unlock_path_for(resource)
  22. # super(resource)
  23. # end
  24. end

app/graphql/mutations/base_mutation.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Mutations
  3. class BaseMutation < GraphQL::Schema::RelayClassicMutation
  4. argument_class Types::BaseArgument
  5. field_class Types::BaseField
  6. input_object_class Types::BaseInputObject
  7. object_class Types::BaseObject
  8. end
  9. end

app/graphql/mutations/gdpr_mutations.rb

0.0% lines covered

100.0% branches covered

199 relevant lines. 0 lines covered and 199 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Mutations
  2. module GdprMutations
  3. # Request personal data export
  4. class RequestDataExport < Mutations::BaseMutation
  5. description "Request export of personal data (GDPR Article 20 - Right to Data Portability)"
  6. argument :user_id, ID, required: true, description: "User ID to export data for"
  7. field :success, Boolean, null: false, description: "Whether the request was successful"
  8. field :message, String, null: false, description: "Response message"
  9. field :export_request, Types::GdprExportRequestType, null: true, description: "Created export request"
  10. field :errors, [String], null: true, description: "List of errors"
  11. def resolve(user_id:)
  12. user = User.find(user_id)
  13. # Check permissions
  14. unless context[:current_user]&.administrator? || context[:current_user] == user
  15. return {
  16. success: false,
  17. message: 'Access denied',
  18. export_request: nil,
  19. errors: ['Insufficient permissions']
  20. }
  21. end
  22. begin
  23. export_request = GdprService.create_export_request(user, context[:current_user])
  24. {
  25. success: true,
  26. message: 'Personal data export request created successfully',
  27. export_request: export_request,
  28. errors: nil
  29. }
  30. rescue => e
  31. {
  32. success: false,
  33. message: 'Failed to create export request',
  34. export_request: nil,
  35. errors: [e.message]
  36. }
  37. end
  38. end
  39. end
  40. # Request personal data erasure
  41. class RequestDataErasure < Mutations::BaseMutation
  42. description "Request erasure of personal data (GDPR Article 17 - Right to Erasure)"
  43. argument :user_id, ID, required: true, description: "User ID to erase data for"
  44. argument :reason, String, required: false, description: "Reason for data erasure"
  45. field :success, Boolean, null: false, description: "Whether the request was successful"
  46. field :message, String, null: false, description: "Response message"
  47. field :erasure_request, Types::GdprErasureRequestType, null: true, description: "Created erasure request"
  48. field :errors, [String], null: true, description: "List of errors"
  49. def resolve(user_id:, reason: nil)
  50. user = User.find(user_id)
  51. # Check permissions
  52. unless context[:current_user]&.administrator? || context[:current_user] == user
  53. return {
  54. success: false,
  55. message: 'Access denied',
  56. erasure_request: nil,
  57. errors: ['Insufficient permissions']
  58. }
  59. end
  60. # Prevent admins from being erased
  61. if user.administrator?
  62. return {
  63. success: false,
  64. message: 'Cannot erase data for administrator accounts',
  65. erasure_request: nil,
  66. errors: ['Administrator accounts cannot be erased']
  67. }
  68. end
  69. begin
  70. erasure_request = GdprService.create_erasure_request(user, context[:current_user], reason)
  71. {
  72. success: true,
  73. message: 'Data erasure request created successfully',
  74. erasure_request: erasure_request,
  75. errors: nil
  76. }
  77. rescue => e
  78. {
  79. success: false,
  80. message: 'Failed to create erasure request',
  81. erasure_request: nil,
  82. errors: [e.message]
  83. }
  84. end
  85. end
  86. end
  87. # Confirm data erasure request
  88. class ConfirmDataErasure < Mutations::BaseMutation
  89. description "Confirm a data erasure request"
  90. argument :token, String, required: true, description: "Erasure request token"
  91. field :success, Boolean, null: false, description: "Whether the confirmation was successful"
  92. field :message, String, null: false, description: "Response message"
  93. field :erasure_request, Types::GdprErasureRequestType, null: true, description: "Confirmed erasure request"
  94. field :errors, [String], null: true, description: "List of errors"
  95. def resolve(token:)
  96. erasure_request = PersonalDataErasureRequest.find_by(token: token)
  97. unless erasure_request
  98. return {
  99. success: false,
  100. message: 'Erasure request not found',
  101. erasure_request: nil,
  102. errors: ['Invalid token']
  103. }
  104. end
  105. if erasure_request.status != 'pending_confirmation'
  106. return {
  107. success: false,
  108. message: 'This request has already been processed',
  109. erasure_request: erasure_request,
  110. errors: ['Request already processed']
  111. }
  112. end
  113. begin
  114. GdprService.confirm_erasure_request(erasure_request, context[:current_user])
  115. {
  116. success: true,
  117. message: 'Data erasure confirmed and queued for processing',
  118. erasure_request: erasure_request,
  119. errors: nil
  120. }
  121. rescue => e
  122. {
  123. success: false,
  124. message: 'Failed to confirm erasure request',
  125. erasure_request: nil,
  126. errors: [e.message]
  127. }
  128. end
  129. end
  130. end
  131. # Record user consent
  132. class RecordConsent < Mutations::BaseMutation
  133. description "Record user consent (GDPR Article 7)"
  134. argument :user_id, ID, required: true, description: "User ID"
  135. argument :consent_type, String, required: true, description: "Type of consent"
  136. argument :consent_data, GraphQL::Types::JSON, required: true, description: "Consent data"
  137. field :success, Boolean, null: false, description: "Whether the consent was recorded successfully"
  138. field :message, String, null: false, description: "Response message"
  139. field :consent_record, Types::GdprConsentRecordType, null: true, description: "Created consent record"
  140. field :errors, [String], null: true, description: "List of errors"
  141. def resolve(user_id:, consent_type:, consent_data:)
  142. user = User.find(user_id)
  143. # Check permissions
  144. unless context[:current_user]&.administrator? || context[:current_user] == user
  145. return {
  146. success: false,
  147. message: 'Access denied',
  148. consent_record: nil,
  149. errors: ['Insufficient permissions']
  150. }
  151. end
  152. begin
  153. consent_record = GdprService.record_user_consent(user, consent_type, consent_data)
  154. {
  155. success: true,
  156. message: 'Consent recorded successfully',
  157. consent_record: consent_record,
  158. errors: nil
  159. }
  160. rescue => e
  161. {
  162. success: false,
  163. message: 'Failed to record consent',
  164. consent_record: nil,
  165. errors: [e.message]
  166. }
  167. end
  168. end
  169. end
  170. # Withdraw user consent
  171. class WithdrawConsent < Mutations::BaseMutation
  172. description "Withdraw user consent"
  173. argument :user_id, ID, required: true, description: "User ID"
  174. argument :consent_type, String, required: true, description: "Type of consent to withdraw"
  175. field :success, Boolean, null: false, description: "Whether the consent was withdrawn successfully"
  176. field :message, String, null: false, description: "Response message"
  177. field :consent_record, Types::GdprConsentRecordType, null: true, description: "Updated consent record"
  178. field :errors, [String], null: true, description: "List of errors"
  179. def resolve(user_id:, consent_type:)
  180. user = User.find(user_id)
  181. # Check permissions
  182. unless context[:current_user]&.administrator? || context[:current_user] == user
  183. return {
  184. success: false,
  185. message: 'Access denied',
  186. consent_record: nil,
  187. errors: ['Insufficient permissions']
  188. }
  189. end
  190. begin
  191. consent_record = GdprService.withdraw_user_consent(user, consent_type)
  192. {
  193. success: true,
  194. message: 'Consent withdrawn successfully',
  195. consent_record: consent_record,
  196. errors: nil
  197. }
  198. rescue => e
  199. {
  200. success: false,
  201. message: 'Failed to withdraw consent',
  202. consent_record: nil,
  203. errors: [e.message]
  204. }
  205. end
  206. end
  207. end
  208. end
  209. end

app/graphql/mutations/meta_fields/create_meta_field.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Mutations
  2. module MetaFields
  3. class CreateMetaField < BaseMutation
  4. description "Create a new meta field for a model"
  5. argument :metable_type, String, required: true, description: "Type of the parent object (Post, Page, User, AiAgent)"
  6. argument :metable_id, ID, required: true, description: "ID of the parent object"
  7. argument :key, String, required: true, description: "The key/name of the meta field"
  8. argument :value, String, required: false, description: "The value of the meta field"
  9. argument :immutable, Boolean, required: false, default_value: false, description: "Whether this meta field can be modified"
  10. field :meta_field, Types::MetaFieldType, null: true, description: "The created meta field"
  11. field :errors, [String], null: false, description: "Any errors that occurred"
  12. def resolve(metable_type:, metable_id:, key:, value: nil, immutable: false)
  13. # Validate metable_type
  14. unless %w[Post Page User AiAgent].include?(metable_type.classify)
  15. return {
  16. meta_field: nil,
  17. errors: ["Invalid metable type. Must be one of: Post, Page, User, AiAgent"]
  18. }
  19. end
  20. begin
  21. # Find the parent object
  22. metable = metable_type.classify.constantize.find(metable_id)
  23. # Create the meta field
  24. meta_field = metable.meta_fields.build(
  25. key: key,
  26. value: value,
  27. immutable: immutable
  28. )
  29. if meta_field.save
  30. {
  31. meta_field: meta_field,
  32. errors: []
  33. }
  34. else
  35. {
  36. meta_field: nil,
  37. errors: meta_field.errors.full_messages
  38. }
  39. end
  40. rescue ActiveRecord::RecordNotFound
  41. {
  42. meta_field: nil,
  43. errors: ["#{metable_type} not found"]
  44. }
  45. rescue => e
  46. {
  47. meta_field: nil,
  48. errors: [e.message]
  49. }
  50. end
  51. end
  52. end
  53. end
  54. end

app/graphql/railspress_schema.rb

0.0% lines covered

100.0% branches covered

19 relevant lines. 0 lines covered and 19 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class RailspressSchema < GraphQL::Schema
  3. mutation(Types::MutationType)
  4. query(Types::QueryType)
  5. # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html)
  6. use GraphQL::Dataloader
  7. # GraphQL-Ruby calls this when something goes wrong while running a query:
  8. def self.type_error(err, context)
  9. # if err.is_a?(GraphQL::InvalidNullError)
  10. # # report to your bug tracker here
  11. # return nil
  12. # end
  13. super
  14. end
  15. # Union and Interface Resolution
  16. def self.resolve_type(abstract_type, obj, ctx)
  17. # TODO: Implement this method
  18. # to return the correct GraphQL object type for `obj`
  19. raise(GraphQL::RequiredImplementationMissingError)
  20. end
  21. # Limit the size of incoming queries:
  22. max_query_string_tokens(5000)
  23. # Stop validating when it encounters this many errors:
  24. validate_max_errors(100)
  25. # Relay-style Object Identification:
  26. # Return a string UUID for `object`
  27. def self.id_from_object(object, type_definition, query_ctx)
  28. # For example, use Rails' GlobalID library (https://github.com/rails/globalid):
  29. object.to_gid_param
  30. end
  31. # Given a string UUID, find the object
  32. def self.object_from_id(global_id, query_ctx)
  33. # For example, use Rails' GlobalID library (https://github.com/rails/globalid):
  34. GlobalID.find(global_id)
  35. end
  36. end

app/graphql/resolvers/base_resolver.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resolvers
  3. class BaseResolver < GraphQL::Schema::Resolver
  4. end
  5. end

app/graphql/resolvers/channel_resolver.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class ChannelResolver < Resolvers::BaseResolver
  3. description "Find a single channel"
  4. argument :id, ID, required: false, description: "Find channel by ID"
  5. argument :slug, String, required: false, description: "Find channel by slug"
  6. type Types::ChannelType, null: true
  7. def resolve(id: nil, slug: nil)
  8. if id.present?
  9. Channel.find(id)
  10. elsif slug.present?
  11. Channel.find_by(slug: slug)
  12. else
  13. raise GraphQL::ExecutionError, "Either id or slug must be provided"
  14. end
  15. end
  16. end
  17. end

app/graphql/resolvers/channels_resolver.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class ChannelsResolver < Resolvers::BaseResolver
  3. description "Find channels"
  4. argument :slug, String, required: false, description: "Find channel by slug"
  5. argument :domain, String, required: false, description: "Find channel by domain"
  6. argument :enabled, Boolean, required: false, description: "Filter by enabled status"
  7. argument :device_type, String, required: false, description: "Filter by device type"
  8. type [Types::ChannelType], null: true
  9. def resolve(slug: nil, domain: nil, enabled: nil, device_type: nil)
  10. channels = Channel.all
  11. channels = channels.where(slug: slug) if slug.present?
  12. channels = channels.where(domain: domain) if domain.present?
  13. channels = channels.where(enabled: enabled) if enabled != nil
  14. if device_type.present?
  15. channels = channels.where("metadata->>'device_type' = ?", device_type)
  16. end
  17. channels.order(:name)
  18. end
  19. end
  20. end

app/graphql/resolvers/image_optimization_resolver.rb

0.0% lines covered

100.0% branches covered

142 relevant lines. 0 lines covered and 142 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class ImageOptimizationResolver < Resolvers::BaseResolver
  3. description "Image optimization queries and mutations"
  4. # Query: Get optimization analytics
  5. field :image_optimization_analytics, Types::ImageOptimizationStatsType, null: false do
  6. description "Get image optimization analytics"
  7. end
  8. # Query: Get optimization logs
  9. field :image_optimization_logs, [Types::ImageOptimizationLogType], null: true do
  10. description "Get image optimization logs"
  11. argument :limit, Integer, required: false, default_value: 50
  12. argument :status, String, required: false
  13. argument :compression_level, String, required: false
  14. argument :optimization_type, String, required: false
  15. end
  16. # Query: Get failed optimizations
  17. field :failed_image_optimizations, [Types::ImageOptimizationLogType], null: true do
  18. description "Get failed image optimizations"
  19. argument :limit, Integer, required: false, default_value: 20
  20. end
  21. # Query: Get top savings
  22. field :top_image_savings, [Types::ImageOptimizationLogType], null: true do
  23. description "Get top image optimization savings"
  24. argument :limit, Integer, required: false, default_value: 10
  25. end
  26. # Query: Get compression levels
  27. field :compression_levels, [Types::CompressionLevelType], null: true do
  28. description "Get available compression levels"
  29. end
  30. # Query: Get optimization report
  31. field :image_optimization_report, Types::ImageOptimizationReportType, null: true do
  32. description "Get image optimization report"
  33. argument :start_date, GraphQL::Types::ISO8601Date, required: false
  34. argument :end_date, GraphQL::Types::ISO8601Date, required: false
  35. end
  36. # Mutation: Bulk optimize images
  37. field :bulk_optimize_images, GraphQL::Types::Boolean, null: false do
  38. description "Start bulk optimization of images"
  39. end
  40. # Mutation: Regenerate variants
  41. field :regenerate_image_variants, GraphQL::Types::Boolean, null: false do
  42. description "Regenerate image variants"
  43. argument :medium_id, ID, required: true
  44. end
  45. # Mutation: Clear optimization logs
  46. field :clear_optimization_logs, GraphQL::Types::Boolean, null: false do
  47. description "Clear all optimization logs"
  48. argument :confirm, Boolean, required: true
  49. end
  50. def image_optimization_analytics
  51. {
  52. total_optimizations: ImageOptimizationLog.count,
  53. successful_optimizations: ImageOptimizationLog.successful.count,
  54. failed_optimizations: ImageOptimizationLog.failed.count,
  55. skipped_optimizations: ImageOptimizationLog.skipped.count,
  56. total_bytes_saved: ImageOptimizationLog.total_bytes_saved || 0,
  57. total_size_saved_mb: ((ImageOptimizationLog.total_bytes_saved || 0) / 1024.0 / 1024.0).round(2),
  58. average_size_reduction: ImageOptimizationLog.average_size_reduction&.round(2),
  59. average_processing_time: ImageOptimizationLog.average_processing_time&.round(3),
  60. today_optimizations: ImageOptimizationLog.today.count,
  61. this_week_optimizations: ImageOptimizationLog.this_week.count,
  62. this_month_optimizations: ImageOptimizationLog.this_month.count
  63. }
  64. end
  65. def image_optimization_logs(limit:, status:, compression_level:, optimization_type:)
  66. logs = ImageOptimizationLog.includes(:medium, :upload, :user)
  67. logs = logs.where(status: status) if status.present?
  68. logs = logs.where(compression_level: compression_level) if compression_level.present?
  69. logs = logs.where(optimization_type: optimization_type) if optimization_type.present?
  70. logs.recent.limit(limit)
  71. end
  72. def failed_image_optimizations(limit:)
  73. ImageOptimizationLog.failed_optimizations
  74. .includes(:medium, :upload, :user)
  75. .limit(limit)
  76. end
  77. def top_image_savings(limit:)
  78. ImageOptimizationLog.top_savings(limit).includes(:medium, :upload, :user)
  79. end
  80. def compression_levels
  81. ImageOptimizationService.available_compression_levels.map do |key, config|
  82. {
  83. name: config[:name],
  84. description: config[:description],
  85. quality: config[:quality],
  86. compression_level: config[:compression_level],
  87. lossy: config[:lossy],
  88. expected_savings: config[:expected_savings],
  89. recommended_for: config[:recommended_for]
  90. }
  91. end
  92. end
  93. def image_optimization_report(start_date:, end_date:)
  94. start_date ||= 30.days.ago.to_date
  95. end_date ||= Date.current
  96. report = ImageOptimizationLog.generate_report(start_date, end_date)
  97. {
  98. total_optimizations: report[:total_optimizations],
  99. successful_optimizations: report[:successful_optimizations],
  100. failed_optimizations: report[:failed_optimizations],
  101. skipped_optimizations: report[:skipped_optimizations],
  102. total_bytes_saved: report[:total_bytes_saved],
  103. total_size_saved_mb: report[:total_size_saved_mb],
  104. average_size_reduction: report[:average_size_reduction],
  105. average_processing_time: report[:average_processing_time],
  106. compression_level_breakdown: report[:compression_level_breakdown],
  107. optimization_type_breakdown: report[:optimization_type_breakdown],
  108. daily_optimizations: report[:daily_optimizations],
  109. top_users: report[:top_users],
  110. top_tenants: report[:top_tenants]
  111. }
  112. end
  113. def bulk_optimize_images
  114. # Get all unoptimized images
  115. unoptimized_uploads = Upload.joins(:media)
  116. .where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
  117. .where.not(file: nil)
  118. return true if unoptimized_uploads.empty?
  119. # Queue optimization jobs
  120. unoptimized_uploads.limit(100).each do |upload|
  121. medium = upload.media.first
  122. if medium
  123. OptimizeImageJob.perform_later(
  124. medium_id: medium.id,
  125. optimization_type: 'bulk',
  126. request_context: {
  127. user_agent: context[:request]&.user_agent,
  128. ip_address: context[:request]&.remote_ip
  129. }
  130. )
  131. end
  132. end
  133. true
  134. end
  135. def regenerate_image_variants(medium_id:)
  136. medium = Medium.find(medium_id)
  137. OptimizeImageJob.perform_later(
  138. medium_id: medium.id,
  139. optimization_type: 'regenerate',
  140. request_context: {
  141. user_agent: context[:request]&.user_agent,
  142. ip_address: context[:request]&.remote_ip
  143. }
  144. )
  145. true
  146. end
  147. def clear_optimization_logs(confirm:)
  148. return false unless confirm
  149. ImageOptimizationLog.delete_all
  150. true
  151. end
  152. end
  153. end

app/graphql/resolvers/media_resolver.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class MediaResolver < Resolvers::BaseResolver
  3. description "Find media files with channel filtering"
  4. argument :channel, String, required: false, description: "Filter media by channel slug"
  5. argument :file_type, String, required: false, description: "Filter by file type"
  6. argument :search, String, required: false, description: "Search in title and description"
  7. argument :limit, Integer, required: false, description: "Limit number of results"
  8. argument :offset, Integer, required: false, description: "Offset for pagination"
  9. type [Types::MediumType], null: true
  10. def resolve(channel: nil, file_type: nil, search: nil, limit: nil, offset: nil)
  11. media = Medium.all
  12. # Apply filters
  13. media = media.where(file_type: file_type) if file_type.present?
  14. media = media.where("title ILIKE ? OR description ILIKE ?", "%#{search}%", "%#{search}%") if search.present?
  15. # Channel filtering
  16. if channel.present?
  17. channel_obj = Channel.find_by(slug: channel)
  18. if channel_obj
  19. media = media.left_joins(:channels)
  20. .where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
  21. # Apply channel exclusions
  22. excluded_media_ids = channel_obj.channel_overrides
  23. .exclusions
  24. .enabled
  25. .where(resource_type: 'Medium')
  26. .pluck(:resource_id)
  27. media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
  28. # Set channel context for serialization
  29. context[:current_channel] = channel_obj
  30. end
  31. end
  32. # Pagination
  33. media = media.limit(limit) if limit.present?
  34. media = media.offset(offset) if offset.present?
  35. media.order(created_at: :desc)
  36. end
  37. end
  38. end

app/graphql/resolvers/pages_resolver.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class PagesResolver < Resolvers::BaseResolver
  3. description "Find pages with channel filtering"
  4. argument :channel, String, required: false, description: "Filter pages by channel slug"
  5. argument :status, String, required: false, description: "Filter by page status"
  6. argument :parent_id, Integer, required: false, description: "Filter by parent page ID"
  7. argument :search, String, required: false, description: "Search in title and content"
  8. argument :limit, Integer, required: false, description: "Limit number of results"
  9. argument :offset, Integer, required: false, description: "Offset for pagination"
  10. type [Types::PageType], null: true
  11. def resolve(channel: nil, status: nil, parent_id: nil, search: nil, limit: nil, offset: nil)
  12. pages = Page.all
  13. # Apply filters
  14. pages = pages.where(status: status) if status.present?
  15. pages = pages.where(parent_id: parent_id) if parent_id.present?
  16. pages = pages.where("title ILIKE ? OR content ILIKE ?", "%#{search}%", "%#{search}%") if search.present?
  17. # Channel filtering
  18. if channel.present?
  19. channel_obj = Channel.find_by(slug: channel)
  20. if channel_obj
  21. pages = pages.left_joins(:channels)
  22. .where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
  23. # Apply channel exclusions
  24. excluded_page_ids = channel_obj.channel_overrides
  25. .exclusions
  26. .enabled
  27. .where(resource_type: 'Page')
  28. .pluck(:resource_id)
  29. pages = pages.where.not(id: excluded_page_ids) if excluded_page_ids.any?
  30. # Set channel context for serialization
  31. context[:current_channel] = channel_obj
  32. end
  33. end
  34. # Only published for non-authenticated users
  35. unless context[:current_user]&.can_edit_others_posts?
  36. pages = pages.published
  37. end
  38. # Pagination
  39. pages = pages.limit(limit) if limit.present?
  40. pages = pages.offset(offset) if offset.present?
  41. pages.order(:order, :title)
  42. end
  43. end
  44. end

app/graphql/resolvers/posts_resolver.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Resolvers
  2. class PostsResolver < Resolvers::BaseResolver
  3. description "Find posts with channel filtering"
  4. argument :channel, String, required: false, description: "Filter posts by channel slug"
  5. argument :status, String, required: false, description: "Filter by post status"
  6. argument :category, String, required: false, description: "Filter by category slug"
  7. argument :tag, String, required: false, description: "Filter by tag slug"
  8. argument :search, String, required: false, description: "Search in title and content"
  9. argument :limit, Integer, required: false, description: "Limit number of results"
  10. argument :offset, Integer, required: false, description: "Offset for pagination"
  11. type [Types::PostType], null: true
  12. def resolve(channel: nil, status: nil, category: nil, tag: nil, search: nil, limit: nil, offset: nil)
  13. posts = Post.all
  14. # Apply filters
  15. posts = posts.where(status: status) if status.present?
  16. posts = posts.by_category(category) if category.present?
  17. posts = posts.by_tag(tag) if tag.present?
  18. posts = posts.search(search) if search.present?
  19. # Channel filtering
  20. if channel.present?
  21. channel_obj = Channel.find_by(slug: channel)
  22. if channel_obj
  23. posts = posts.left_joins(:channels)
  24. .where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
  25. # Apply channel exclusions
  26. excluded_post_ids = channel_obj.channel_overrides
  27. .exclusions
  28. .enabled
  29. .where(resource_type: 'Post')
  30. .pluck(:resource_id)
  31. posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
  32. # Set channel context for serialization
  33. context[:current_channel] = channel_obj
  34. end
  35. end
  36. # Only published for non-authenticated users
  37. unless context[:current_user]&.can_edit_others_posts?
  38. posts = posts.published
  39. end
  40. # Pagination
  41. posts = posts.limit(limit) if limit.present?
  42. posts = posts.offset(offset) if offset.present?
  43. posts.order(created_at: :desc)
  44. end
  45. end
  46. end

app/graphql/types/ai_agent_type.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class AiAgentType < Types::BaseObject
  3. description "An AI agent for automated tasks"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :description, String, null: true
  7. field :prompt, String, null: false
  8. field :guidelines, String, null: true
  9. field :tasks, String, null: true
  10. field :agent_type, String, null: false
  11. field :active, Boolean, null: false
  12. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  13. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  14. # AI Provider
  15. field :ai_provider, Types::BaseObject, null: true do
  16. description "The AI provider this agent uses"
  17. end
  18. # Usage statistics
  19. field :usage_count, Integer, null: false, description: "Number of times this agent has been used"
  20. field :last_used_at, GraphQL::Types::ISO8601DateTime, null: true, description: "When this agent was last used"
  21. # Meta Fields
  22. field :meta_fields, [Types::MetaFieldType], null: true, description: "Custom meta fields for this AI agent" do
  23. argument :key, String, required: false, description: "Filter by specific meta field key"
  24. argument :immutable, Boolean, required: false, description: "Filter by immutable status"
  25. end
  26. field :meta_field, Types::MetaFieldType, null: true, description: "Get a specific meta field by key" do
  27. argument :key, String, required: true, description: "The key of the meta field to retrieve"
  28. end
  29. field :all_meta, GraphQL::Types::JSON, null: true, description: "All meta fields as a key-value hash"
  30. def ai_provider
  31. object.ai_provider
  32. end
  33. def usage_count
  34. object.ai_usages.count
  35. end
  36. def last_used_at
  37. object.ai_usages.order(created_at: :desc).first&.created_at
  38. end
  39. def meta_fields(key: nil, immutable: nil)
  40. meta_fields = object.meta_fields
  41. meta_fields = meta_fields.by_key(key) if key.present?
  42. meta_fields = meta_fields.immutable if immutable == true
  43. meta_fields = meta_fields.mutable if immutable == false
  44. meta_fields
  45. end
  46. def meta_field(key:)
  47. object.meta_fields.find_by(key: key)
  48. end
  49. def all_meta
  50. object.all_meta
  51. end
  52. end
  53. end

app/graphql/types/analytics_type.rb

0.0% lines covered

100.0% branches covered

103 relevant lines. 0 lines covered and 103 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class AnalyticsType < Types::BaseObject
  4. description "Analytics data for posts and pages"
  5. field :total_views, Integer, null: false, description: "Total number of page views"
  6. field :unique_readers, Integer, null: false, description: "Number of unique readers"
  7. field :medium_readers, Integer, null: false, description: "Number of Medium-like readers (30+ seconds)"
  8. field :reader_conversion_rate, Float, null: false, description: "Percentage of visitors who become readers"
  9. field :returning_readers, Integer, null: false, description: "Number of returning readers"
  10. field :avg_reading_time, Integer, null: false, description: "Average reading time in seconds"
  11. field :avg_engagement_score, Float, null: false, description: "Average engagement score (0-100)"
  12. field :avg_scroll_depth, Integer, null: false, description: "Average scroll depth percentage"
  13. field :avg_completion_rate, Float, null: false, description: "Average completion rate percentage"
  14. field :avg_time_on_page, Integer, null: false, description: "Average time on page in seconds"
  15. field :readers_who_scrolled_to_bottom, Integer, null: false, description: "Readers who scrolled to bottom"
  16. field :readers_who_spent_time, Integer, null: false, description: "Readers who spent significant time"
  17. field :readers_with_exit_intent, Integer, null: false, description: "Readers who showed exit intent"
  18. field :readers_by_country, [Types::CountryAnalyticsType], null: false, description: "Reader demographics by country"
  19. field :readers_by_device, [Types::DeviceAnalyticsType], null: false, description: "Reader demographics by device"
  20. field :readers_by_browser, [Types::BrowserAnalyticsType], null: false, description: "Reader demographics by browser"
  21. field :traffic_sources, [Types::TrafficSourceType], null: false, description: "Traffic sources analysis"
  22. field :direct_traffic, Integer, null: false, description: "Direct traffic count"
  23. field :organic_traffic, Integer, null: false, description: "Organic search traffic count"
  24. field :social_traffic, Integer, null: false, description: "Social media traffic count"
  25. end
  26. class CountryAnalyticsType < Types::BaseObject
  27. description "Analytics data by country"
  28. field :country_code, String, null: false, description: "Country code (ISO 3166-1 alpha-2)"
  29. field :country_name, String, null: false, description: "Full country name"
  30. field :count, Integer, null: false, description: "Number of readers from this country"
  31. field :percentage, Float, null: false, description: "Percentage of total readers"
  32. end
  33. class DeviceAnalyticsType < Types::BaseObject
  34. description "Analytics data by device type"
  35. field :device, String, null: false, description: "Device type"
  36. field :count, Integer, null: false, description: "Number of readers using this device"
  37. field :percentage, Float, null: false, description: "Percentage of total readers"
  38. end
  39. class BrowserAnalyticsType < Types::BaseObject
  40. description "Analytics data by browser"
  41. field :browser, String, null: false, description: "Browser name"
  42. field :count, Integer, null: false, description: "Number of readers using this browser"
  43. field :percentage, Float, null: false, description: "Percentage of total readers"
  44. end
  45. class TrafficSourceType < Types::BaseObject
  46. description "Traffic source information"
  47. field :referrer, String, null: false, description: "Referrer URL or source name"
  48. field :count, Integer, null: false, description: "Number of visits from this source"
  49. field :percentage, Float, null: false, description: "Percentage of total traffic"
  50. end
  51. class RealtimeAnalyticsType < Types::BaseObject
  52. description "Real-time analytics data"
  53. field :active_users, Integer, null: false, description: "Currently active users"
  54. field :current_pageviews, Integer, null: false, description: "Current pageviews count"
  55. field :top_pages_now, [Types::PageAnalyticsType], null: false, description: "Top pages being viewed now"
  56. field :active_countries, [Types::CountryAnalyticsType], null: false, description: "Active countries"
  57. field :timestamp, GraphQL::Types::ISO8601DateTime, null: false, description: "Data timestamp"
  58. end
  59. class PageAnalyticsType < Types::BaseObject
  60. description "Page analytics summary"
  61. field :path, String, null: false, description: "Page path"
  62. field :title, String, null: true, description: "Page title"
  63. field :views, Integer, null: false, description: "Number of views"
  64. end
  65. class AnalyticsOverviewType < Types::BaseObject
  66. description "Complete analytics overview"
  67. field :total_pageviews, Integer, null: false, description: "Total pageviews for period"
  68. field :unique_visitors, Integer, null: false, description: "Unique visitors for period"
  69. field :top_posts, [Types::ContentAnalyticsType], null: false, description: "Top performing posts"
  70. field :top_pages, [Types::ContentAnalyticsType], null: false, description: "Top performing pages"
  71. field :traffic_sources, [Types::TrafficSourceType], null: false, description: "Traffic sources"
  72. field :audience_insights, Types::AudienceInsightsType, null: false, description: "Audience insights"
  73. field :period, String, null: false, description: "Analytics period"
  74. field :generated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Data generation timestamp"
  75. end
  76. class ContentAnalyticsType < Types::BaseObject
  77. description "Content analytics summary"
  78. field :id, ID, null: false, description: "Content ID"
  79. field :title, String, null: false, description: "Content title"
  80. field :slug, String, null: false, description: "Content slug"
  81. field :views, Integer, null: false, description: "Number of views"
  82. field :unique_readers, Integer, null: false, description: "Number of unique readers"
  83. field :medium_readers, Integer, null: false, description: "Number of Medium-like readers"
  84. field :avg_engagement_score, Float, null: false, description: "Average engagement score"
  85. field :avg_reading_time, Integer, null: false, description: "Average reading time"
  86. field :published_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Publication date"
  87. end
  88. class AudienceInsightsType < Types::BaseObject
  89. description "Audience insights data"
  90. field :top_countries, [Types::CountryAnalyticsType], null: false, description: "Top countries by traffic"
  91. field :browsers, [Types::BrowserAnalyticsType], null: false, description: "Browser distribution"
  92. field :devices, [Types::DeviceAnalyticsType], null: false, description: "Device distribution"
  93. field :operating_systems, [Types::OperatingSystemAnalyticsType], null: false, description: "OS distribution"
  94. field :avg_session_duration, Integer, null: false, description: "Average session duration in seconds"
  95. field :bounce_rate, Float, null: false, description: "Bounce rate percentage"
  96. field :pages_per_session, Float, null: false, description: "Average pages per session"
  97. end
  98. class OperatingSystemAnalyticsType < Types::BaseObject
  99. description "Analytics data by operating system"
  100. field :os, String, null: false, description: "Operating system name"
  101. field :count, Integer, null: false, description: "Number of readers using this OS"
  102. field :percentage, Float, null: false, description: "Percentage of total readers"
  103. end
  104. end

app/graphql/types/base_argument.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseArgument < GraphQL::Schema::Argument
  4. end
  5. end

app/graphql/types/base_connection.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseConnection < Types::BaseObject
  4. # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides
  5. include GraphQL::Types::Relay::ConnectionBehaviors
  6. end
  7. end

app/graphql/types/base_edge.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseEdge < Types::BaseObject
  4. # add `node` and `cursor` fields, as well as `node_type(...)` override
  5. include GraphQL::Types::Relay::EdgeBehaviors
  6. end
  7. end

app/graphql/types/base_enum.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseEnum < GraphQL::Schema::Enum
  4. end
  5. end

app/graphql/types/base_field.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseField < GraphQL::Schema::Field
  4. argument_class Types::BaseArgument
  5. end
  6. end

app/graphql/types/base_input_object.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseInputObject < GraphQL::Schema::InputObject
  4. argument_class Types::BaseArgument
  5. end
  6. end

app/graphql/types/base_interface.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. module BaseInterface
  4. include GraphQL::Schema::Interface
  5. edge_type_class(Types::BaseEdge)
  6. connection_type_class(Types::BaseConnection)
  7. field_class Types::BaseField
  8. end
  9. end

app/graphql/types/base_object.rb

0.0% lines covered

100.0% branches covered

7 relevant lines. 0 lines covered and 7 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseObject < GraphQL::Schema::Object
  4. edge_type_class(Types::BaseEdge)
  5. connection_type_class(Types::BaseConnection)
  6. field_class Types::BaseField
  7. end
  8. end

app/graphql/types/base_scalar.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseScalar < GraphQL::Schema::Scalar
  4. end
  5. end

app/graphql/types/base_union.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class BaseUnion < GraphQL::Schema::Union
  4. edge_type_class(Types::BaseEdge)
  5. connection_type_class(Types::BaseConnection)
  6. end
  7. end

app/graphql/types/category_type.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class CategoryType < Types::BaseObject
  3. description "A category (hierarchical term)"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :slug, String, null: false
  7. field :description, String, null: true
  8. field :count, Integer, null: false
  9. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  10. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  11. # Hierarchy
  12. field :parent, Types::CategoryType, null: true
  13. field :children, [Types::CategoryType], null: true
  14. # Associated content
  15. field :posts, [Types::PostType], null: true do
  16. argument :limit, Integer, required: false
  17. end
  18. def posts(limit: nil)
  19. posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
  20. posts = posts.published
  21. posts = posts.limit(limit) if limit
  22. posts
  23. end
  24. end
  25. end

app/graphql/types/channel_override_type.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class ChannelOverrideType < Types::BaseObject
  3. description "A channel override for customizing content per channel"
  4. field :id, ID, null: false
  5. field :kind, String, null: false, description: "Type of override: 'override' or 'exclude'"
  6. field :path, String, null: false, description: "JSON path to the field being overridden"
  7. field :data, GraphQL::Types::JSON, null: true, description: "Override data"
  8. field :enabled, Boolean, null: false
  9. field :resource_type, String, null: false
  10. field :resource_id, Integer, null: true
  11. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  12. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  13. # Associations
  14. field :channel, Types::ChannelType, null: false
  15. field :resource, Types::NodeType, null: true
  16. # Computed fields
  17. field :is_override, Boolean, null: false
  18. field :is_exclusion, Boolean, null: false
  19. field :resource_name, String, null: true
  20. def is_override
  21. object.kind == 'override'
  22. end
  23. def is_exclusion
  24. object.kind == 'exclude'
  25. end
  26. def resource_name
  27. return nil unless object.resource_id
  28. case object.resource_type
  29. when 'Post'
  30. Post.find_by(id: object.resource_id)&.title
  31. when 'Page'
  32. Page.find_by(id: object.resource_id)&.title
  33. when 'Medium'
  34. Medium.find_by(id: object.resource_id)&.title
  35. else
  36. "#{object.resource_type} ##{object.resource_id}"
  37. end
  38. end
  39. end
  40. end

app/graphql/types/channel_type.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class ChannelType < Types::BaseObject
  3. description "A content channel for distributing content across different platforms"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :slug, String, null: false
  7. field :domain, String, null: true
  8. field :locale, String, null: false
  9. field :enabled, Boolean, null: false
  10. field :metadata, GraphQL::Types::JSON, null: true
  11. field :settings, GraphQL::Types::JSON, null: true
  12. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  13. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  14. # Associations
  15. field :posts, [Types::PostType], null: true
  16. field :pages, [Types::PageType], null: true
  17. field :media, [Types::MediumType], null: true
  18. field :overrides, [Types::ChannelOverrideType], null: true
  19. # Computed fields
  20. field :content_count, Integer, null: false
  21. field :override_count, Integer, null: false
  22. field :device_type, String, null: true
  23. field :target_audience, String, null: true
  24. def content_count
  25. object.posts.count + object.pages.count + object.media.count
  26. end
  27. def override_count
  28. object.channel_overrides.count
  29. end
  30. def device_type
  31. object.metadata&.dig('device_type')
  32. end
  33. def target_audience
  34. object.metadata&.dig('target_audience')
  35. end
  36. end
  37. end

app/graphql/types/comment_type.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class CommentType < Types::BaseObject
  3. description "A comment on a post or page"
  4. field :id, ID, null: false
  5. field :content, String, null: false
  6. field :author_name, String, null: true
  7. field :author_email, String, null: true
  8. field :status, String, null: false
  9. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  10. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  11. # Author (user)
  12. field :user, Types::UserType, null: true
  13. # Commentable (polymorphic)
  14. field :commentable_type, String, null: false
  15. field :commentable_id, ID, null: false
  16. field :post, Types::PostType, null: true
  17. field :page, Types::PageType, null: true
  18. # Threading
  19. field :parent, Types::CommentType, null: true
  20. field :replies, [Types::CommentType], null: true
  21. def post
  22. object.commentable if object.commentable_type == 'Post'
  23. end
  24. def page
  25. object.commentable if object.commentable_type == 'Page'
  26. end
  27. end
  28. end

app/graphql/types/content_type_type.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class ContentTypeType < Types::BaseObject
  3. description "A content type (custom post type)"
  4. field :id, ID, null: false
  5. field :ident, String, null: false, description: "Unique identifier for the content type"
  6. field :label, String, null: false, description: "Display label"
  7. field :singular, String, null: false, description: "Singular name"
  8. field :plural, String, null: false, description: "Plural name"
  9. field :description, String, null: true, description: "Description of the content type"
  10. field :icon, String, null: true, description: "Icon name"
  11. field :public, Boolean, null: false, description: "Is visible on frontend"
  12. field :hierarchical, Boolean, null: false, description: "Supports parent/child relationships"
  13. field :has_archive, Boolean, null: false, description: "Has archive page"
  14. field :menu_position, Integer, null: true, description: "Position in admin menu"
  15. field :supports, [String], null: false, description: "Features this type supports"
  16. field :capabilities, GraphQL::Types::JSON, null: true, description: "Custom capabilities"
  17. field :rest_base, String, null: false, description: "REST API endpoint base"
  18. field :active, Boolean, null: false, description: "Is currently active"
  19. field :posts_count, Integer, null: false, description: "Number of posts of this type"
  20. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  21. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  22. field :posts, [Types::PostType], null: false, description: "Posts of this content type"
  23. def posts_count
  24. object.posts.count
  25. end
  26. def rest_base
  27. object.rest_endpoint
  28. end
  29. end
  30. end

app/graphql/types/gdpr_type.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class GdprType < Types::BaseObject
  3. description "GDPR compliance information"
  4. field :user_id, ID, null: false, description: "User ID"
  5. field :email, String, null: false, description: "User email"
  6. field :compliance_status, Types::GdprComplianceStatusType, null: false, description: "GDPR compliance status"
  7. field :data_retention, Types::GdprDataRetentionType, null: false, description: "Data retention information"
  8. field :pending_requests, Types::GdprPendingRequestsType, null: false, description: "Pending GDPR requests"
  9. field :data_categories, Types::GdprDataCategoriesType, null: false, description: "Data categories held"
  10. field :legal_basis, Types::GdprLegalBasisType, null: false, description: "Legal basis for processing"
  11. field :export_requests, [Types::GdprExportRequestType], null: false, description: "Data export requests"
  12. field :erasure_requests, [Types::GdprErasureRequestType], null: false, description: "Data erasure requests"
  13. field :consent_records, [Types::GdprConsentRecordType], null: false, description: "User consent records"
  14. def export_requests
  15. PersonalDataExportRequest.where(user_id: object[:user_id])
  16. end
  17. def erasure_requests
  18. PersonalDataErasureRequest.where(user_id: object[:user_id])
  19. end
  20. def consent_records
  21. UserConsent.where(user_id: object[:user_id])
  22. end
  23. end
  24. class GdprComplianceStatusType < Types::BaseObject
  25. description "GDPR compliance status"
  26. field :data_processing_consent, String, null: false, description: "Data processing consent status"
  27. field :marketing_consent, String, null: false, description: "Marketing consent status"
  28. field :analytics_consent, String, null: false, description: "Analytics consent status"
  29. field :cookie_consent, String, null: false, description: "Cookie consent status"
  30. end
  31. class GdprDataRetentionType < Types::BaseObject
  32. description "Data retention information"
  33. field :account_created, GraphQL::Types::ISO8601DateTime, null: false, description: "Account creation date"
  34. field :last_activity, GraphQL::Types::ISO8601DateTime, null: true, description: "Last activity date"
  35. field :data_age_days, Integer, null: false, description: "Data age in days"
  36. end
  37. class GdprPendingRequestsType < Types::BaseObject
  38. description "Pending GDPR requests"
  39. field :export_requests, Integer, null: false, description: "Number of pending export requests"
  40. field :erasure_requests, Integer, null: false, description: "Number of pending erasure requests"
  41. end
  42. class GdprDataCategoriesType < Types::BaseObject
  43. description "Data categories held"
  44. field :profile_data, Boolean, null: false, description: "Profile data held"
  45. field :content_data, Boolean, null: false, description: "Content data held"
  46. field :communication_data, Boolean, null: false, description: "Communication data held"
  47. field :analytics_data, Boolean, null: false, description: "Analytics data held"
  48. field :media_data, Boolean, null: false, description: "Media data held"
  49. field :subscription_data, Boolean, null: false, description: "Subscription data held"
  50. end
  51. class GdprLegalBasisType < Types::BaseObject
  52. description "Legal basis for processing"
  53. field :consent, Boolean, null: false, description: "Processing based on consent"
  54. field :withhold_consent, Boolean, null: false, description: "Consent has been withdrawn"
  55. field :legitimate_interest, Boolean, null: false, description: "Processing based on legitimate interest"
  56. end
  57. class GdprExportRequestType < Types::BaseObject
  58. description "GDPR data export request"
  59. field :id, ID, null: false, description: "Request ID"
  60. field :email, String, null: false, description: "User email"
  61. field :status, String, null: false, description: "Request status"
  62. field :requested_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Request date"
  63. field :completed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Completion date"
  64. field :download_url, String, null: true, description: "Download URL (if completed)"
  65. field :token, String, null: false, description: "Access token"
  66. end
  67. class GdprErasureRequestType < Types::BaseObject
  68. description "GDPR data erasure request"
  69. field :id, ID, null: false, description: "Request ID"
  70. field :email, String, null: false, description: "User email"
  71. field :status, String, null: false, description: "Request status"
  72. field :reason, String, null: true, description: "Erasure reason"
  73. field :requested_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Request date"
  74. field :confirmed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Confirmation date"
  75. field :completed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Completion date"
  76. field :confirmation_url, String, null: true, description: "Confirmation URL (if pending)"
  77. field :metadata, GraphQL::Types::JSON, null: true, description: "Request metadata"
  78. end
  79. class GdprConsentRecordType < Types::BaseObject
  80. description "GDPR consent record"
  81. field :id, ID, null: false, description: "Record ID"
  82. field :consent_type, String, null: false, description: "Type of consent"
  83. field :granted, Boolean, null: false, description: "Whether consent is granted"
  84. field :consent_text, String, null: false, description: "Consent text"
  85. field :granted_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Grant date"
  86. field :withdrawn_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Withdrawal date"
  87. field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Creation date"
  88. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Last update date"
  89. end
  90. class GdprDataPortabilityType < Types::BaseObject
  91. description "GDPR data portability information"
  92. field :user_profile, GraphQL::Types::JSON, null: false, description: "User profile data"
  93. field :posts, [GraphQL::Types::JSON], null: false, description: "User posts"
  94. field :pages, [GraphQL::Types::JSON], null: false, description: "User pages"
  95. field :comments, [GraphQL::Types::JSON], null: false, description: "User comments"
  96. field :media, [GraphQL::Types::JSON], null: false, description: "User media"
  97. field :subscribers, [GraphQL::Types::JSON], null: false, description: "User subscriptions"
  98. field :api_tokens, [GraphQL::Types::JSON], null: false, description: "User API tokens"
  99. field :meta_fields, [GraphQL::Types::JSON], null: false, description: "User meta fields"
  100. field :analytics_data, GraphQL::Types::JSON, null: false, description: "User analytics data"
  101. field :consent_records, [GraphQL::Types::JSON], null: false, description: "User consent records"
  102. field :gdpr_requests, GraphQL::Types::JSON, null: false, description: "GDPR requests history"
  103. field :metadata, GraphQL::Types::JSON, null: false, description: "Export metadata"
  104. end
  105. class GdprAuditLogEntryType < Types::BaseObject
  106. description "GDPR audit log entry"
  107. field :id, ID, null: false, description: "Entry ID"
  108. field :action, String, null: false, description: "Action performed"
  109. field :user_email, String, null: false, description: "User email"
  110. field :timestamp, GraphQL::Types::ISO8601DateTime, null: false, description: "Action timestamp"
  111. field :details, GraphQL::Types::JSON, null: true, description: "Action details"
  112. end
  113. end

app/graphql/types/image_optimization_log_type.rb

0.0% lines covered

100.0% branches covered

78 relevant lines. 0 lines covered and 78 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class ImageOptimizationLogType < Types::BaseObject
  3. description "Image optimization log entry"
  4. field :id, ID, null: false
  5. field :filename, String, null: true
  6. field :content_type, String, null: true
  7. field :original_size, Integer, null: true
  8. field :optimized_size, Integer, null: true
  9. field :bytes_saved, Integer, null: true
  10. field :size_reduction_percentage, Float, null: true
  11. field :size_reduction_mb, Float, null: true
  12. field :compression_level, String, null: true
  13. field :compression_level_name, String, null: true
  14. field :quality, Integer, null: true
  15. field :processing_time, Float, null: true
  16. field :processing_time_formatted, String, null: true
  17. field :status, String, null: true
  18. field :optimization_type, String, null: true
  19. field :variants_generated, [String], null: true
  20. field :responsive_variants_generated, [String], null: true
  21. field :error_message, String, null: true
  22. field :warnings, [String], null: true
  23. field :created_at, GraphQL::Types::ISO8601DateTime, null: true
  24. field :updated_at, GraphQL::Types::ISO8601DateTime, null: true
  25. field :user, Types::UserType, null: true
  26. field :medium, Types::MediumType, null: true
  27. field :upload, Types::UploadType, null: true
  28. def compression_level_name
  29. ImageOptimizationService.available_compression_levels[object.compression_level]&.dig(:name) || object.compression_level&.capitalize
  30. end
  31. def size_reduction_mb
  32. object.size_reduction_mb
  33. end
  34. def processing_time_formatted
  35. object.processing_time_formatted
  36. end
  37. end
  38. class ImageOptimizationStatsType < Types::BaseObject
  39. description "Image optimization statistics"
  40. field :total_optimizations, Integer, null: false
  41. field :successful_optimizations, Integer, null: false
  42. field :failed_optimizations, Integer, null: false
  43. field :skipped_optimizations, Integer, null: false
  44. field :total_bytes_saved, Integer, null: false
  45. field :total_size_saved_mb, Float, null: false
  46. field :average_size_reduction, Float, null: true
  47. field :average_processing_time, Float, null: true
  48. field :today_optimizations, Integer, null: false
  49. field :this_week_optimizations, Integer, null: false
  50. field :this_month_optimizations, Integer, null: false
  51. end
  52. class CompressionLevelType < Types::BaseObject
  53. description "Compression level configuration"
  54. field :name, String, null: false
  55. field :description, String, null: false
  56. field :quality, Integer, null: false
  57. field :compression_level, Integer, null: false
  58. field :lossy, Boolean, null: false
  59. field :expected_savings, String, null: false
  60. field :recommended_for, String, null: false
  61. end
  62. class ImageOptimizationReportType < Types::BaseObject
  63. description "Image optimization report"
  64. field :total_optimizations, Integer, null: false
  65. field :successful_optimizations, Integer, null: false
  66. field :failed_optimizations, Integer, null: false
  67. field :skipped_optimizations, Integer, null: false
  68. field :total_bytes_saved, Integer, null: false
  69. field :total_size_saved_mb, Float, null: false
  70. field :average_size_reduction, Float, null: true
  71. field :average_processing_time, Float, null: true
  72. field :compression_level_breakdown, GraphQL::Types::JSON, null: true
  73. field :optimization_type_breakdown, GraphQL::Types::JSON, null: true
  74. field :daily_optimizations, GraphQL::Types::JSON, null: true
  75. field :top_users, GraphQL::Types::JSON, null: true
  76. field :top_tenants, GraphQL::Types::JSON, null: true
  77. end
  78. end

app/graphql/types/media_type.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class MediaType < Types::BaseObject
  3. field :id, ID, null: false
  4. field :title, String, null: false
  5. field :description, String, null: true
  6. field :alt_text, String, null: true
  7. # File information (from upload)
  8. field :filename, String, null: true
  9. field :content_type, String, null: true
  10. field :file_size, Integer, null: true
  11. field :url, String, null: true
  12. # File type flags
  13. field :image, Boolean, null: false
  14. field :video, Boolean, null: false
  15. field :document, Boolean, null: false
  16. # Security status
  17. field :quarantined, Boolean, null: false
  18. field :quarantine_reason, String, null: true
  19. field :approved, Boolean, null: false
  20. # Timestamps
  21. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  22. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  23. # Relationships
  24. field :user, Types::UserType, null: false
  25. field :upload, Types::UploadType, null: false
  26. # Helper methods for file type flags
  27. def image
  28. object.image?
  29. end
  30. def video
  31. object.video?
  32. end
  33. def document
  34. object.document?
  35. end
  36. def quarantined
  37. object.quarantined?
  38. end
  39. def approved
  40. object.approved?
  41. end
  42. def file_size
  43. object.file_size
  44. end
  45. end
  46. end

app/graphql/types/medium_type.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class MediumType < Types::BaseObject
  3. description "A media file with channel support"
  4. field :id, ID, null: false
  5. field :title, String, null: false
  6. field :file_name, String, null: false
  7. field :file_type, String, null: false
  8. field :description, String, null: true
  9. field :alt_text, String, null: true
  10. field :file_size, Integer, null: true
  11. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  12. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  13. # Channel support
  14. field :channels, [Types::ChannelType], null: true
  15. field :channel_context, String, null: true, description: "Current channel context"
  16. field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
  17. # Computed fields
  18. field :url, String, null: true
  19. field :content_type, String, null: false
  20. field :file_extension, String, null: true
  21. field :is_image, Boolean, null: false
  22. field :is_video, Boolean, null: false
  23. field :is_document, Boolean, null: false
  24. def url
  25. object.file_url if object.respond_to?(:file_url)
  26. end
  27. def content_type
  28. 'media'
  29. end
  30. def file_extension
  31. File.extname(object.file_name).downcase[1..-1]
  32. end
  33. def is_image
  34. %w[jpg jpeg png gif webp svg].include?(file_extension)
  35. end
  36. def is_video
  37. %w[mp4 avi mov wmv flv webm].include?(file_extension)
  38. end
  39. def is_document
  40. %w[pdf doc docx txt rtf].include?(file_extension)
  41. end
  42. end
  43. end

app/graphql/types/meta_field_input_type.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class MetaFieldInputType < Types::BaseInputObject
  3. description "Input for creating or updating a meta field"
  4. argument :key, String, required: true, description: "The key/name of the meta field"
  5. argument :value, String, required: false, description: "The value of the meta field"
  6. argument :immutable, Boolean, required: false, default_value: false, description: "Whether this meta field can be modified"
  7. end
  8. class MetaFieldBulkInputType < Types::BaseInputObject
  9. description "Input for bulk creating or updating meta fields"
  10. argument :meta_fields, [MetaFieldInputType], required: true, description: "Array of meta fields to create or update"
  11. end
  12. class MetaFieldUpdateInputType < Types::BaseInputObject
  13. description "Input for updating an existing meta field"
  14. argument :value, String, required: false, description: "The new value of the meta field"
  15. argument :immutable, Boolean, required: false, description: "Whether this meta field can be modified"
  16. end
  17. end

app/graphql/types/meta_field_type.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class MetaFieldType < Types::BaseObject
  3. description "A meta field for storing custom data on models"
  4. field :id, ID, null: false, description: "Unique identifier for the meta field"
  5. field :key, String, null: false, description: "The key/name of the meta field"
  6. field :value, String, null: true, description: "The value of the meta field"
  7. field :immutable, Boolean, null: false, description: "Whether this meta field can be modified"
  8. field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: "When the meta field was created"
  9. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "When the meta field was last updated"
  10. field :metable_type, String, null: false, description: "The type of object this meta field belongs to"
  11. field :metable_id, ID, null: false, description: "The ID of the object this meta field belongs to"
  12. # Helper method to get the parent object
  13. field :metable, GraphQL::Types::JSON, null: true, description: "The parent object this meta field belongs to" do
  14. def resolve(object, args, context)
  15. # Return basic info about the metable without exposing the full object
  16. {
  17. type: object.metable_type,
  18. id: object.metable_id
  19. }
  20. end
  21. end
  22. # JSON value helper
  23. field :json_value, GraphQL::Types::JSON, null: true, description: "The value parsed as JSON if valid" do
  24. def resolve(object, args, context)
  25. object.json_value
  26. end
  27. end
  28. # Type-casted value helpers
  29. field :int_value, Integer, null: true, description: "The value as an integer" do
  30. def resolve(object, args, context)
  31. object.to_i
  32. end
  33. end
  34. field :float_value, Float, null: true, description: "The value as a float" do
  35. def resolve(object, args, context)
  36. object.to_f
  37. end
  38. end
  39. field :bool_value, Boolean, null: true, description: "The value as a boolean" do
  40. def resolve(object, args, context)
  41. object.to_bool
  42. end
  43. end
  44. end
  45. end

app/graphql/types/mutation_type.rb

0.0% lines covered

100.0% branches covered

64 relevant lines. 0 lines covered and 64 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class MutationType < Types::BaseObject
  3. description "The mutation root of the RailsPress GraphQL API"
  4. # Example mutations - can be expanded
  5. # TODO: Add full CRUD mutations for posts, pages, comments, etc.
  6. field :test_field, String, null: false do
  7. description "An example field added by the generator"
  8. end
  9. def test_field
  10. "Hello World from RailsPress GraphQL!"
  11. end
  12. # ========== IMAGE OPTIMIZATION MUTATIONS ==========
  13. field :bulk_optimize_images, GraphQL::Types::Boolean, null: false do
  14. description "Start bulk optimization of images"
  15. end
  16. field :regenerate_image_variants, GraphQL::Types::Boolean, null: false do
  17. description "Regenerate image variants"
  18. argument :medium_id, ID, required: true
  19. end
  20. field :clear_optimization_logs, GraphQL::Types::Boolean, null: false do
  21. description "Clear all optimization logs"
  22. argument :confirm, Boolean, required: true
  23. end
  24. def bulk_optimize_images
  25. # Get all unoptimized images
  26. unoptimized_uploads = Upload.joins(:media)
  27. .where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
  28. .where.not(file: nil)
  29. return true if unoptimized_uploads.empty?
  30. # Queue optimization jobs
  31. unoptimized_uploads.limit(100).each do |upload|
  32. medium = upload.media.first
  33. if medium
  34. OptimizeImageJob.perform_later(
  35. medium_id: medium.id,
  36. optimization_type: 'bulk',
  37. request_context: {
  38. user_agent: context[:request]&.user_agent,
  39. ip_address: context[:request]&.remote_ip
  40. }
  41. )
  42. end
  43. end
  44. true
  45. end
  46. def regenerate_image_variants(medium_id:)
  47. medium = Medium.find(medium_id)
  48. OptimizeImageJob.perform_later(
  49. medium_id: medium.id,
  50. optimization_type: 'regenerate',
  51. request_context: {
  52. user_agent: context[:request]&.user_agent,
  53. ip_address: context[:request]&.remote_ip
  54. }
  55. )
  56. true
  57. end
  58. def clear_optimization_logs(confirm:)
  59. return false unless confirm
  60. ImageOptimizationLog.delete_all
  61. true
  62. end
  63. # ========== GDPR COMPLIANCE MUTATIONS ==========
  64. field :request_data_export, mutation: Mutations::GdprMutations::RequestDataExport
  65. field :request_data_erasure, mutation: Mutations::GdprMutations::RequestDataErasure
  66. field :confirm_data_erasure, mutation: Mutations::GdprMutations::ConfirmDataErasure
  67. field :record_consent, mutation: Mutations::GdprMutations::RecordConsent
  68. field :withdraw_consent, mutation: Mutations::GdprMutations::WithdrawConsent
  69. end
  70. end

app/graphql/types/node_type.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. module NodeType
  4. include Types::BaseInterface
  5. # Add the `id` field
  6. include GraphQL::Types::Relay::NodeBehaviors
  7. end
  8. end

app/graphql/types/page_type.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class PageType < Types::BaseObject
  3. description "A page with channel support"
  4. field :id, ID, null: false
  5. field :title, String, null: false
  6. field :slug, String, null: false
  7. field :content, String, null: true
  8. field :status, String, null: false
  9. field :published_at, GraphQL::Types::ISO8601DateTime, null: true
  10. field :parent_id, Integer, null: true
  11. field :order, Integer, null: true
  12. field :template, String, null: true
  13. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  14. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  15. # Channel support
  16. field :channels, [Types::ChannelType], null: true
  17. field :channel_context, String, null: true, description: "Current channel context"
  18. field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
  19. # Associations
  20. field :user, Types::UserType, null: true
  21. field :parent, Types::PageType, null: true
  22. field :children, [Types::PageType], null: true
  23. # Computed fields
  24. field :url, String, null: true
  25. field :author_name, String, null: true
  26. field :content_type, String, null: false
  27. def url
  28. Rails.application.routes.url_helpers.page_url(object, host: context[:request]&.host)
  29. rescue
  30. nil
  31. end
  32. def author_name
  33. object.user&.display_name || object.user&.email
  34. end
  35. def content_type
  36. 'page'
  37. end
  38. end
  39. end

app/graphql/types/post_type.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class PostType < Types::BaseObject
  3. description "A blog post with channel support"
  4. field :id, ID, null: false
  5. field :title, String, null: false
  6. field :slug, String, null: false
  7. field :content, String, null: true
  8. field :excerpt, String, null: true
  9. field :status, String, null: false
  10. field :published_at, GraphQL::Types::ISO8601DateTime, null: true
  11. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  12. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  13. # Channel support
  14. field :channels, [Types::ChannelType], null: true
  15. field :channel_context, String, null: true, description: "Current channel context"
  16. field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
  17. # Associations
  18. field :user, Types::UserType, null: true
  19. field :categories, [Types::TermType], null: true
  20. field :tags, [Types::TermType], null: true
  21. field :comments, [Types::CommentType], null: true
  22. # Computed fields
  23. field :url, String, null: true
  24. field :author_name, String, null: true
  25. field :content_type, String, null: false
  26. def url
  27. Rails.application.routes.url_helpers.blog_post_url(object, host: context[:request]&.host)
  28. rescue
  29. nil
  30. end
  31. def author_name
  32. object.user&.display_name || object.user&.email
  33. end
  34. def content_type
  35. 'post'
  36. end
  37. end
  38. end

app/graphql/types/query_type.rb

0.0% lines covered

100.0% branches covered

26 relevant lines. 0 lines covered and 26 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Types
  3. class QueryType < Types::BaseObject
  4. field :node, Types::NodeType, null: true, description: "Fetches an object given its ID." do
  5. argument :id, ID, required: true, description: "ID of the object."
  6. end
  7. def node(id:)
  8. context.schema.object_from_id(id, context)
  9. end
  10. field :nodes, [Types::NodeType, null: true], null: true, description: "Fetches a list of objects given a list of IDs." do
  11. argument :ids, [ID], required: true, description: "IDs of the objects."
  12. end
  13. def nodes(ids:)
  14. ids.map { |id| context.schema.object_from_id(id, context) }
  15. end
  16. # Add root-level fields here.
  17. # They will be entry points for queries on your schema.
  18. # Channel queries
  19. field :channels, resolver: Resolvers::ChannelsResolver, description: "Find channels"
  20. field :channel, resolver: Resolvers::ChannelResolver, description: "Find a single channel"
  21. # Content queries with channel support
  22. field :posts, resolver: Resolvers::PostsResolver, description: "Find posts with channel filtering"
  23. field :pages, resolver: Resolvers::PagesResolver, description: "Find pages with channel filtering"
  24. field :media, resolver: Resolvers::MediaResolver, description: "Find media with channel filtering"
  25. # TODO: remove me
  26. field :test_field, String, null: false,
  27. description: "An example field added by the generator"
  28. def test_field
  29. "Hello World!"
  30. end
  31. end
  32. end

app/graphql/types/search_results_type.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class SearchResultsType < Types::BaseObject
  3. description "Search results across posts and pages"
  4. field :posts, [Types::PostType], null: false
  5. field :pages, [Types::PageType], null: false
  6. field :total, Integer, null: false
  7. end
  8. end

app/graphql/types/storage_provider_type.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class StorageProviderType < Types::BaseObject
  3. field :id, ID, null: false
  4. field :name, String, null: false
  5. field :provider_type, String, null: false
  6. field :active, Boolean, null: false
  7. field :position, Integer, null: true
  8. # Provider type flags
  9. field :local, Boolean, null: false
  10. field :s3, Boolean, null: false
  11. field :gcs, Boolean, null: false
  12. field :azure, Boolean, null: false
  13. # Timestamps
  14. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  15. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  16. # Helper methods for provider type flags
  17. def local
  18. object.local?
  19. end
  20. def s3
  21. object.s3?
  22. end
  23. def gcs
  24. object.gcs?
  25. end
  26. def azure
  27. object.azure?
  28. end
  29. end
  30. end

app/graphql/types/subscriber_type.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class SubscriberType < Types::BaseObject
  3. description "A newsletter subscriber"
  4. field :id, ID, null: false
  5. field :email, String, null: false
  6. field :name, String, null: true
  7. field :status, String, null: false
  8. field :source, String, null: true
  9. field :tags, [String], null: true
  10. field :lists, [String], null: true
  11. field :confirmed_at, GraphQL::Types::ISO8601DateTime, null: true
  12. field :unsubscribed_at, GraphQL::Types::ISO8601DateTime, null: true
  13. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  14. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  15. # Check if can receive emails
  16. field :can_receive_emails, Boolean, null: false
  17. def can_receive_emails
  18. object.can_receive_emails?
  19. end
  20. end
  21. end

app/graphql/types/tag_type.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class TagType < Types::BaseObject
  3. description "A tag (non-hierarchical term)"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :slug, String, null: false
  7. field :description, String, null: true
  8. field :count, Integer, null: false
  9. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  10. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  11. # Associated content
  12. field :posts, [Types::PostType], null: true do
  13. argument :limit, Integer, required: false
  14. end
  15. def posts(limit: nil)
  16. posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
  17. posts = posts.published
  18. posts = posts.limit(limit) if limit
  19. posts
  20. end
  21. end
  22. end

app/graphql/types/taxonomy_type.rb

0.0% lines covered

100.0% branches covered

31 relevant lines. 0 lines covered and 31 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class TaxonomyType < Types::BaseObject
  3. description "A custom taxonomy"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :slug, String, null: false
  7. field :description, String, null: true
  8. field :hierarchical, Boolean, null: false
  9. field :object_types, [String], null: true
  10. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  11. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  12. # Terms
  13. field :terms, [Types::TermType], null: true do
  14. argument :parent_id, ID, required: false
  15. argument :limit, Integer, required: false
  16. end
  17. # Counts
  18. field :term_count, Integer, null: false
  19. def terms(parent_id: nil, limit: nil)
  20. terms = object.terms
  21. if parent_id
  22. terms = terms.where(parent_id: parent_id)
  23. elsif object.hierarchical
  24. terms = terms.where(parent_id: nil) # Only root terms for hierarchical
  25. end
  26. terms = terms.limit(limit) if limit
  27. terms
  28. end
  29. def term_count
  30. object.terms.count
  31. end
  32. end
  33. end

app/graphql/types/term_type.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class TermType < Types::BaseObject
  3. description "A taxonomy term"
  4. field :id, ID, null: false
  5. field :name, String, null: false
  6. field :slug, String, null: false
  7. field :description, String, null: true
  8. field :count, Integer, null: false
  9. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  10. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  11. # Taxonomy
  12. field :taxonomy, Types::TaxonomyType, null: false
  13. # Hierarchy
  14. field :parent, Types::TermType, null: true
  15. field :children, [Types::TermType], null: true
  16. # Associated content
  17. field :posts, [Types::PostType], null: true do
  18. argument :limit, Integer, required: false
  19. end
  20. field :pages, [Types::PageType], null: true do
  21. argument :limit, Integer, required: false
  22. end
  23. def posts(limit: nil)
  24. posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
  25. posts = posts.published
  26. posts = posts.limit(limit) if limit
  27. posts
  28. end
  29. def pages(limit: nil)
  30. pages = Page.joins(:term_relationships).where(term_relationships: { term_id: object.id })
  31. pages = pages.published
  32. pages = pages.limit(limit) if limit
  33. pages
  34. end
  35. end
  36. end

app/graphql/types/upload_type.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class UploadType < Types::BaseObject
  3. field :id, ID, null: false
  4. field :title, String, null: false
  5. field :description, String, null: true
  6. field :alt_text, String, null: true
  7. # File information
  8. field :filename, String, null: true
  9. field :content_type, String, null: true
  10. field :file_size, Integer, null: true
  11. field :url, String, null: true
  12. # File type flags
  13. field :image, Boolean, null: false
  14. field :video, Boolean, null: false
  15. field :document, Boolean, null: false
  16. # Security status
  17. field :quarantined, Boolean, null: false
  18. field :quarantine_reason, String, null: true
  19. field :approved, Boolean, null: false
  20. # Timestamps
  21. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  22. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  23. # Relationships
  24. field :user, Types::UserType, null: false
  25. field :storage_provider, Types::StorageProviderType, null: true
  26. field :media, [Types::MediaType], null: false
  27. # Helper methods for file type flags
  28. def image
  29. object.image?
  30. end
  31. def video
  32. object.video?
  33. end
  34. def document
  35. object.document?
  36. end
  37. def quarantined
  38. object.quarantined?
  39. end
  40. def approved
  41. object.approved?
  42. end
  43. def file_size
  44. object.file_size
  45. end
  46. end
  47. end

app/graphql/types/user_type.rb

0.0% lines covered

100.0% branches covered

74 relevant lines. 0 lines covered and 74 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Types
  2. class UserType < Types::BaseObject
  3. description "A user in the system"
  4. field :id, ID, null: false
  5. field :email, String, null: false
  6. field :role, String, null: false
  7. field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  8. field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
  9. # Associations
  10. field :posts, [Types::PostType], null: true do
  11. description "Posts created by this user"
  12. argument :status, String, required: false
  13. argument :limit, Integer, required: false
  14. end
  15. field :pages, [Types::PageType], null: true do
  16. description "Pages created by this user"
  17. argument :status, String, required: false
  18. argument :limit, Integer, required: false
  19. end
  20. field :comments, [Types::CommentType], null: true do
  21. description "Comments by this user"
  22. argument :limit, Integer, required: false
  23. end
  24. # Computed fields
  25. field :post_count, Integer, null: false
  26. field :page_count, Integer, null: false
  27. field :is_admin, Boolean, null: false
  28. # Meta Fields
  29. field :meta_fields, [Types::MetaFieldType], null: true, description: "Custom meta fields for this user" do
  30. argument :key, String, required: false, description: "Filter by specific meta field key"
  31. argument :immutable, Boolean, required: false, description: "Filter by immutable status"
  32. end
  33. field :meta_field, Types::MetaFieldType, null: true, description: "Get a specific meta field by key" do
  34. argument :key, String, required: true, description: "The key of the meta field to retrieve"
  35. end
  36. field :all_meta, GraphQL::Types::JSON, null: true, description: "All meta fields as a key-value hash"
  37. def posts(status: nil, limit: nil)
  38. posts = object.posts
  39. posts = posts.where(status: status) if status
  40. posts = posts.limit(limit) if limit
  41. posts
  42. end
  43. def pages(status: nil, limit: nil)
  44. pages = object.pages
  45. pages = pages.where(status: status) if status
  46. pages = pages.limit(limit) if limit
  47. pages
  48. end
  49. def comments(limit: nil)
  50. comments = object.comments
  51. comments = comments.limit(limit) if limit
  52. comments
  53. end
  54. def post_count
  55. object.posts.count
  56. end
  57. def page_count
  58. object.pages.count
  59. end
  60. def is_admin
  61. object.administrator?
  62. end
  63. def meta_fields(key: nil, immutable: nil)
  64. meta_fields = object.meta_fields
  65. meta_fields = meta_fields.by_key(key) if key.present?
  66. meta_fields = meta_fields.immutable if immutable == true
  67. meta_fields = meta_fields.mutable if immutable == false
  68. meta_fields
  69. end
  70. def meta_field(key:)
  71. object.meta_fields.find_by(key: key)
  72. end
  73. def all_meta
  74. object.all_meta
  75. end
  76. end
  77. end

app/helpers/admin/ai_agents_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::AiAgentsHelper
  2. end

app/helpers/admin/ai_helper.rb

35.29% lines covered

100.0% branches covered

17 relevant lines. 6 lines covered and 11 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::AiHelper
  2. # Render an AI Assistant button that opens the AI popup
  3. 1 def ai_assistant_button(options = {})
  4. options[:class] ||= "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition text-sm"
  5. options[:text] ||= "AI Assistant"
  6. content_tag :button, type: "button", onclick: "openAiPopup()", class: options[:class] do
  7. content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
  8. content_tag(:path, "", stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2", d: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z")
  9. end +
  10. content_tag(:span, options[:text])
  11. end
  12. end
  13. # Render the AI popup modal (call this once per page)
  14. 1 def ai_popup_modal
  15. render 'shared/ai_popup'
  16. end
  17. # Render an AI button with content editor label
  18. 1 def ai_content_editor_label(form, field, label_text = nil)
  19. label_text ||= field.to_s.humanize
  20. content_tag :div, class: "flex items-center justify-between mb-2" do
  21. form.label(field, label_text, class: "block text-sm font-medium text-gray-300") +
  22. ai_assistant_button
  23. end
  24. end
  25. # Check if AI agents are available
  26. 1 def ai_agents_available?
  27. AiAgent.active.any?
  28. end
  29. # Get available agent types
  30. 1 def available_ai_agents
  31. AiAgent.active.pluck(:agent_type, :name).map { |type, name| [type, name] }
  32. end
  33. end

app/helpers/admin/ai_providers_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::AiProvidersHelper
  2. end

app/helpers/admin/categories_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::CategoriesHelper
  2. end

app/helpers/admin/comments_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::CommentsHelper
  2. end

app/helpers/admin/dashboard_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::DashboardHelper
  2. end

app/helpers/admin/media_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::MediaHelper
  2. end

app/helpers/admin/menus_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::MenusHelper
  2. end

app/helpers/admin/pages_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::PagesHelper
  2. end

app/helpers/admin/plugins_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::PluginsHelper
  2. end

app/helpers/admin/posts_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::PostsHelper
  2. end

app/helpers/admin/settings/redis_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::Settings::RedisHelper
  2. end

app/helpers/admin/site_settings_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::SiteSettingsHelper
  2. end

app/helpers/admin/tags_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::TagsHelper
  2. end

app/helpers/admin/taxonomies_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::TaxonomiesHelper
  2. end

app/helpers/admin/template_customizer_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::TemplateCustomizerHelper
  2. end

app/helpers/admin/terms_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::TermsHelper
  2. end

app/helpers/admin/themes_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::ThemesHelper
  2. end

app/helpers/admin/webhooks_helper.rb

50.0% lines covered

100.0% branches covered

4 relevant lines. 2 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::WebhooksHelper
  2. 1 def event_description(event)
  3. descriptions = {
  4. 'post.created' => 'Triggered when a new post is created',
  5. 'post.updated' => 'Triggered when a post is updated',
  6. 'post.published' => 'Triggered when a post is published',
  7. 'post.deleted' => 'Triggered when a post is deleted',
  8. 'page.created' => 'Triggered when a new page is created',
  9. 'page.updated' => 'Triggered when a page is updated',
  10. 'page.published' => 'Triggered when a page is published',
  11. 'page.deleted' => 'Triggered when a page is deleted',
  12. 'comment.created' => 'Triggered when a new comment is created',
  13. 'comment.approved' => 'Triggered when a comment is approved',
  14. 'comment.spam' => 'Triggered when a comment is marked as spam',
  15. 'user.created' => 'Triggered when a new user is created',
  16. 'user.updated' => 'Triggered when a user is updated',
  17. 'media.uploaded' => 'Triggered when media is uploaded'
  18. }
  19. descriptions[event] || 'Webhook event description not available'
  20. end
  21. end

app/helpers/admin/widgets_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Admin::WidgetsHelper
  2. end

app/helpers/admin_assets_helper.rb

50.0% lines covered

100.0% branches covered

12 relevant lines. 6 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module AdminAssetsHelper
  2. # Define CSS files needed for each admin page
  3. ADMIN_PAGE_ASSETS = {
  4. 1 'theme_editor' => %w[admin/theme_editor theme_editor_tabs],
  5. 'api_docs' => %w[admin/api_docs],
  6. 'users' => %w[admin/shared tabulator_custom],
  7. 'posts' => %w[admin/shared tabulator_custom],
  8. 'pages' => %w[admin/shared tabulator_custom],
  9. 'comments' => %w[admin/shared tabulator_custom],
  10. 'media' => %w[admin/shared],
  11. 'settings' => %w[admin/shared],
  12. 'ai_agents' => %w[admin/shared],
  13. 'plugins' => %w[admin/shared],
  14. 'content_types' => %w[admin/shared tabulator_custom],
  15. 'categories' => %w[admin/shared tabulator_custom],
  16. 'tags' => %w[admin/shared tabulator_custom],
  17. 'subscribers' => %w[admin/shared tabulator_custom],
  18. 'analytics' => %w[admin/shared],
  19. 'trash' => %w[admin/shared tabulator_custom],
  20. 'cache' => %w[admin/shared],
  21. 'logs' => %w[admin/shared],
  22. 'integrations' => %w[admin/shared],
  23. 'pixels' => %w[admin/shared],
  24. 'pixel_preview' => %w[admin/pixel_preview],
  25. 'template_customizer' => %w[admin/shared],
  26. 'tools' => %w[admin/shared],
  27. 'fonts' => %w[admin/shared],
  28. 'terms' => %w[admin/shared tabulator_custom],
  29. 'field_groups' => %w[admin/shared tabulator_custom],
  30. 'shortcodes' => %w[admin/shared],
  31. 'email_logs' => %w[admin/shared],
  32. 'redirects' => %w[admin/shared tabulator_custom],
  33. 'system' => %w[admin/shared],
  34. 'dashboard' => %w[admin/shared]
  35. }.freeze
  36. # Define JavaScript files needed for each admin page
  37. ADMIN_PAGE_JAVASCRIPT = {
  38. 1 'theme_editor' => %w[theme_editor_tabs_controller],
  39. 'posts' => %w[keyboard_shortcuts_controller],
  40. 'settings' => %w[appearance_preview_controller email_settings_controller post_by_email_controller],
  41. 'ai_agents' => %w[ai_agents_controller ai_agent_chat_controller],
  42. 'analytics' => %w[analytics_controller],
  43. 'cache' => %w[cache_controller],
  44. 'logs' => %w[log_viewer_controller],
  45. 'tools' => %w[import_tools_controller],
  46. 'users' => %w[tabulator_controller],
  47. 'pages' => %w[tabulator_controller],
  48. 'comments' => %w[tabulator_controller],
  49. 'media' => %w[media_library_controller],
  50. 'content_types' => %w[tabulator_controller],
  51. 'categories' => %w[tabulator_controller],
  52. 'tags' => %w[tabulator_controller],
  53. 'subscribers' => %w[tabulator_controller],
  54. 'trash' => %w[tabulator_controller],
  55. 'terms' => %w[tabulator_controller],
  56. 'field_groups' => %w[tabulator_controller],
  57. 'redirects' => %w[tabulator_controller]
  58. }.freeze
  59. 1 def admin_stylesheets_for_page(page_name)
  60. assets = ADMIN_PAGE_ASSETS[page_name.to_s] || %w[admin/shared]
  61. assets.map { |asset| stylesheet_link_tag(asset, "data-turbo-track": "reload") }.join("\n").html_safe
  62. end
  63. 1 def admin_javascripts_for_page(page_name)
  64. assets = ADMIN_PAGE_JAVASCRIPT[page_name.to_s] || []
  65. # Stimulus controllers are automatically loaded, so we just need to ensure they're available
  66. # This method can be extended to load specific JS files if needed
  67. "".html_safe
  68. end
  69. 1 def admin_page_assets(page_name)
  70. content_for :stylesheets, admin_stylesheets_for_page(page_name)
  71. content_for :javascripts, admin_javascripts_for_page(page_name)
  72. end
  73. end

app/helpers/ai_text_generator_helper.rb

21.88% lines covered

0.0% branches covered

32 relevant lines. 7 lines covered and 25 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. 1 module AiTextGeneratorHelper
  2. # Helper method to easily add AI text generation to form fields
  3. #
  4. # Usage:
  5. # <%= ai_text_field(form, :title, 'Post Title', agent: 'content_summarizer') %>
  6. # <%= ai_text_area(form, :content, 'Content', agent: 'creative_writer', rows: 6) %>
  7. #
  8. 1 def ai_text_field(form, field_name, label_text, agent: 'content_summarizer', **options)
  9. field_id = "#{form.object_name}_#{field_name}"
  10. html = content_tag(:div, class: "ai-text-field-wrapper") do
  11. # Label
  12. concat form.label(field_name, label_text, class: "block text-sm font-medium text-gray-300 mb-2")
  13. # Field with AI button
  14. concat content_tag(:div, class: "relative") do
  15. concat form.text_field(field_name,
  16. class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg focus:border-indigo-500 focus:outline-none pr-10 #{options[:class]}",
  17. placeholder: options[:placeholder],
  18. id: field_id,
  19. **options.except(:class, :placeholder))
  20. # Use a placeholder for the AI generator in tests
  21. then: 0 if Rails.env.test?
  22. concat content_tag(:div, "AI Generator Placeholder", class: "ai-text-generator", "data-agent-id" => agent, "data-target-selector" => "##{field_id}")
  23. else: 0 else
  24. concat render('shared/ai_text_generator',
  25. agent_id: agent,
  26. target_selector: "##{field_id}",
  27. button_text: 'AI',
  28. placeholder: options[:ai_placeholder] || "Describe what you want to generate...",
  29. button_class: 'absolute top-2 right-2')
  30. end
  31. end
  32. end
  33. html
  34. end
  35. 1 def ai_text_area(form, field_name, label_text, agent: 'content_summarizer', **options)
  36. field_id = "#{form.object_name}_#{field_name}"
  37. rows = options.delete(:rows) || 4
  38. html = content_tag(:div, class: "ai-text-area-wrapper") do
  39. # Label
  40. concat form.label(field_name, label_text, class: "block text-sm font-medium text-gray-300 mb-2")
  41. # Field with AI button
  42. concat content_tag(:div, class: "relative") do
  43. concat form.text_area(field_name,
  44. class: "w-full px-4 py-3 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg focus:border-indigo-500 focus:outline-none pr-12 #{options[:class]}",
  45. placeholder: options[:placeholder],
  46. rows: rows,
  47. id: field_id,
  48. **options.except(:class, :placeholder, :rows))
  49. # Use a placeholder for the AI generator in tests
  50. then: 0 if Rails.env.test?
  51. concat content_tag(:div, "AI Generator Placeholder", class: "ai-text-generator", "data-agent-id" => agent, "data-target-selector" => "##{field_id}")
  52. else: 0 else
  53. concat render('shared/ai_text_generator',
  54. agent_id: agent,
  55. target_selector: "##{field_id}",
  56. button_text: 'AI',
  57. placeholder: options[:ai_placeholder] || "Describe what you want to generate...",
  58. button_class: 'absolute top-3 right-3')
  59. end
  60. end
  61. end
  62. html
  63. end
  64. # Helper to check if AI agents are available
  65. 1 def ai_agents_available?
  66. AiAgent.active.exists?
  67. end
  68. # Helper to get available AI agents for dropdown
  69. 1 def ai_agent_options
  70. AiAgent.active.ordered.map { |agent| [agent.name, agent.id] }
  71. end
  72. # Helper to render AI text generator with custom styling
  73. 1 def ai_text_generator_button(agent_id:, target_selector:, **options)
  74. render('shared/ai_text_generator',
  75. agent_id: agent_id,
  76. target_selector: target_selector,
  77. button_text: options[:button_text] || 'AI',
  78. placeholder: options[:placeholder] || 'Describe what you want to generate...',
  79. button_class: options[:button_class] || '')
  80. end
  81. # Helper for admin forms - adds AI button to existing field
  82. 1 def with_ai_generator(field_html, agent_id:, target_selector:, **options)
  83. content_tag(:div, class: "relative") do
  84. concat field_html.html_safe
  85. concat ai_text_generator_button(
  86. agent_id: agent_id,
  87. target_selector: target_selector,
  88. **options
  89. )
  90. end
  91. end
  92. end

app/helpers/analytics_helper.rb

5.46% lines covered

0.0% branches covered

348 relevant lines. 19 lines covered and 329 lines missed.
279 total branches, 0 branches covered and 279 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AnalyticsHelper
  3. # Render analytics tracking script with comprehensive GDPR compliance
  4. # Automatically includes GDPR consent management, privacy controls, and data subject rights
  5. #
  6. # @return [String] Rendered HTML
  7. 1 def render_analytics_tracker
  8. then: 0 else: 0 return '' if admin_page?
  9. else: 0 then: 0 return '' unless analytics_enabled?
  10. # Base analytics tracker
  11. tracker = content_tag(:div, '',
  12. data: {
  13. controller: 'ga4-analytics',
  14. 'ga4-analytics-consent-required-value': analytics_require_consent?,
  15. 'ga4-analytics-anonymize-ip-value': analytics_anonymize_ip?,
  16. 'ga4-analytics-debug-value': Rails.env.development?,
  17. 'ga4-analytics-gdpr-enabled-value': gdpr_compliance_enabled?,
  18. 'ga4-analytics-data-retention-days-value': analytics_data_retention_days,
  19. turbo_permanent: true
  20. },
  21. class: 'analytics-tracker'
  22. )
  23. # GDPR consent banner (if consent required)
  24. consent_banner = ''
  25. then: 0 else: 0 if analytics_require_consent?
  26. consent_banner = content_tag(:div, '',
  27. data: { 'ga4-analytics-target': 'consentBanner' },
  28. class: 'fixed bottom-4 right-4 bg-indigo-600 text-white p-4 rounded-lg shadow-lg max-w-md hidden z-50'
  29. ) do
  30. content_tag(:div, class: 'flex items-start space-x-3') do
  31. content_tag(:div, class: 'flex-shrink-0') do
  32. content_tag(:svg, class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24') do
  33. content_tag(:path, '', stroke_linecap: 'round', stroke_linejoin: 'round', stroke_width: '2', d: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z')
  34. end
  35. end +
  36. content_tag(:div, class: 'flex-1') do
  37. content_tag(:h3, 'Privacy & Analytics', class: 'text-sm font-medium mb-2') +
  38. content_tag(:p, analytics_consent_message, class: 'text-xs text-indigo-100 mb-3') +
  39. content_tag(:div, class: 'flex flex-col space-y-2') do
  40. content_tag(:div, class: 'flex space-x-2') do
  41. content_tag(:button, 'Accept All',
  42. data: { action: 'click->ga4-analytics#acceptAllConsent' },
  43. class: 'bg-white text-indigo-600 px-3 py-1 rounded text-xs font-medium hover:bg-indigo-50 transition'
  44. ) +
  45. content_tag(:button, 'Reject All',
  46. data: { action: 'click->ga4-analytics#rejectAllConsent' },
  47. class: 'bg-indigo-500 text-white px-3 py-1 rounded text-xs font-medium hover:bg-indigo-400 transition'
  48. )
  49. end +
  50. content_tag(:button, 'Manage Preferences',
  51. data: { action: 'click->ga4-analytics#showConsentPreferences' },
  52. class: 'text-xs text-indigo-200 underline hover:text-white transition'
  53. )
  54. end
  55. end
  56. end
  57. end
  58. end
  59. # Privacy controls panel (hidden by default)
  60. privacy_controls = content_tag(:div, '',
  61. data: { 'ga4-analytics-target': 'privacyControls' },
  62. class: 'fixed inset-0 bg-black bg-opacity-50 hidden z-50'
  63. ) do
  64. content_tag(:div, class: 'flex items-center justify-center min-h-screen p-4') do
  65. content_tag(:div, class: 'bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-screen overflow-y-auto') do
  66. content_tag(:div, class: 'p-6') do
  67. content_tag(:div, class: 'flex items-center justify-between mb-6') do
  68. content_tag(:h2, 'Privacy Preferences', class: 'text-xl font-semibold text-gray-900') +
  69. content_tag(:button, '×',
  70. data: { action: 'click->ga4-analytics#hideConsentPreferences' },
  71. class: 'text-gray-400 hover:text-gray-600 text-2xl font-bold'
  72. )
  73. end +
  74. content_tag(:div, class: 'space-y-6') do
  75. # Essential cookies (always required)
  76. content_tag(:div) do
  77. content_tag(:h3, 'Essential Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
  78. content_tag(:p, 'These cookies are necessary for the website to function and cannot be switched off.', class: 'text-sm text-gray-600 mb-4') +
  79. content_tag(:div, class: 'flex items-center justify-between') do
  80. content_tag(:span, 'Always Active', class: 'text-sm font-medium text-green-600') +
  81. content_tag(:div, class: 'w-12 h-6 bg-green-500 rounded-full flex items-center justify-end px-1') do
  82. content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full')
  83. end
  84. end
  85. end +
  86. # Analytics cookies
  87. content_tag(:div) do
  88. content_tag(:h3, 'Analytics Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
  89. content_tag(:p, 'These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously.', class: 'text-sm text-gray-600 mb-4') +
  90. content_tag(:div, class: 'flex items-center justify-between') do
  91. content_tag(:span, 'Analytics Tracking', class: 'text-sm font-medium text-gray-700') +
  92. content_tag(:button, '',
  93. data: { action: 'click->ga4-analytics#toggleAnalyticsConsent' },
  94. class: 'w-12 h-6 bg-gray-300 rounded-full flex items-center px-1 transition-colors'
  95. ) do
  96. content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full shadow-md transition-transform')
  97. end
  98. end
  99. end +
  100. # Marketing cookies
  101. content_tag(:div) do
  102. content_tag(:h3, 'Marketing Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
  103. content_tag(:p, 'These cookies are used to track visitors across websites to display relevant and engaging advertisements.', class: 'text-sm text-gray-600 mb-4') +
  104. content_tag(:div, class: 'flex items-center justify-between') do
  105. content_tag(:span, 'Marketing Tracking', class: 'text-sm font-medium text-gray-700') +
  106. content_tag(:button, '',
  107. data: { action: 'click->ga4-analytics#toggleMarketingConsent' },
  108. class: 'w-12 h-6 bg-gray-300 rounded-full flex items-center px-1 transition-colors'
  109. ) do
  110. content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full shadow-md transition-transform')
  111. end
  112. end
  113. end +
  114. # Data subject rights
  115. content_tag(:div, class: 'border-t pt-6') do
  116. content_tag(:h3, 'Your Rights', class: 'text-lg font-medium text-gray-900 mb-4') +
  117. content_tag(:div, class: 'grid grid-cols-1 md:grid-cols-2 gap-4') do
  118. content_tag(:button, 'Access My Data',
  119. data: { action: 'click->ga4-analytics#requestDataAccess' },
  120. class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
  121. ) do
  122. content_tag(:div, class: 'font-medium text-gray-900') { 'Access My Data' } +
  123. content_tag(:div, class: 'text-sm text-gray-600') { 'Download a copy of your personal data' }
  124. end +
  125. content_tag(:button, 'Delete My Data',
  126. data: { action: 'click->ga4-analytics#requestDataDeletion' },
  127. class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
  128. ) do
  129. content_tag(:div, class: 'font-medium text-gray-900') { 'Delete My Data' } +
  130. content_tag(:div, class: 'text-sm text-gray-600') { 'Request deletion of your personal data' }
  131. end +
  132. content_tag(:button, 'Data Portability',
  133. data: { action: 'click->ga4-analytics#requestDataPortability' },
  134. class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
  135. ) do
  136. content_tag(:div, class: 'font-medium text-gray-900') { 'Data Portability' } +
  137. content_tag(:div, class: 'text-sm text-gray-600') { 'Export your data in a portable format' }
  138. end +
  139. content_tag(:button, 'Contact DPO',
  140. data: { action: 'click->ga4-analytics#contactDPO' },
  141. class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
  142. ) do
  143. content_tag(:div, class: 'font-medium text-gray-900') { 'Contact DPO' } +
  144. content_tag(:div, class: 'text-sm text-gray-600') { 'Contact our Data Protection Officer' }
  145. end
  146. end
  147. end +
  148. # Action buttons
  149. content_tag(:div, class: 'flex space-x-3 pt-6 border-t') do
  150. content_tag(:button, 'Save Preferences',
  151. data: { action: 'click->ga4-analytics#saveConsentPreferences' },
  152. class: 'flex-1 bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-indigo-700 transition'
  153. ) +
  154. content_tag(:button, 'Accept All',
  155. data: { action: 'click->ga4-analytics#acceptAllConsent' },
  156. class: 'flex-1 bg-green-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-green-700 transition'
  157. )
  158. end
  159. end
  160. end
  161. end
  162. end
  163. end
  164. tracker + consent_banner + privacy_controls
  165. end
  166. # Check if analytics is enabled
  167. 1 def analytics_enabled?
  168. SiteSetting.get('analytics_enabled', 'true') == 'true'
  169. rescue
  170. true
  171. end
  172. # Check if GDPR compliance is enabled
  173. 1 def gdpr_compliance_enabled?
  174. SiteSetting.get('gdpr_compliance_enabled', 'true') == 'true'
  175. rescue
  176. true
  177. end
  178. # Check if analytics requires consent
  179. 1 def analytics_require_consent?
  180. SiteSetting.get('analytics_require_consent', 'true') == 'true'
  181. rescue
  182. true
  183. end
  184. # Get analytics consent message
  185. 1 def analytics_consent_message
  186. SiteSetting.get('analytics_consent_message', 'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.')
  187. rescue
  188. 'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.'
  189. end
  190. # Check if IP anonymization is enabled
  191. 1 def analytics_anonymize_ip?
  192. SiteSetting.get('analytics_anonymize_ip', 'true') == 'true'
  193. rescue
  194. true
  195. end
  196. # Check if bot tracking is enabled
  197. 1 def analytics_track_bots?
  198. SiteSetting.get('analytics_track_bots', 'false') == 'true'
  199. rescue
  200. false
  201. end
  202. # Get data retention period
  203. 1 def analytics_data_retention_days
  204. SiteSetting.get('analytics_data_retention_days', 365).to_i
  205. rescue
  206. 365
  207. end
  208. # Helper methods for content analytics views
  209. 1 def flag_for_country(country_code)
  210. # Return flag emoji for country code
  211. then: 0 else: 0 when: 0 case country_code&.upcase
  212. when: 0 when 'US' then '🇺🇸'
  213. when: 0 when 'GB' then '🇬🇧'
  214. when: 0 when 'CA' then '🇨🇦'
  215. when: 0 when 'AU' then '🇦🇺'
  216. when: 0 when 'DE' then '🇩🇪'
  217. when: 0 when 'FR' then '🇫🇷'
  218. when: 0 when 'IT' then '🇮🇹'
  219. when: 0 when 'ES' then '🇪🇸'
  220. when: 0 when 'NL' then '🇳🇱'
  221. when: 0 when 'SE' then '🇸🇪'
  222. when: 0 when 'NO' then '🇳🇴'
  223. when: 0 when 'DK' then '🇩🇰'
  224. when: 0 when 'FI' then '🇫🇮'
  225. when: 0 when 'JP' then '🇯🇵'
  226. when: 0 when 'CN' then '🇨🇳'
  227. when: 0 when 'IN' then '🇮🇳'
  228. when: 0 when 'BR' then '🇧🇷'
  229. when: 0 when 'MX' then '🇲🇽'
  230. when: 0 when 'AR' then '🇦🇷'
  231. when: 0 when 'CL' then '🇨🇱'
  232. when: 0 when 'CO' then '🇨🇴'
  233. when: 0 when 'PE' then '🇵🇪'
  234. when: 0 when 'VE' then '🇻🇪'
  235. when: 0 when 'RU' then '🇷🇺'
  236. when: 0 when 'KR' then '🇰🇷'
  237. when: 0 when 'TH' then '🇹🇭'
  238. when: 0 when 'SG' then '🇸🇬'
  239. when: 0 when 'MY' then '🇲🇾'
  240. when: 0 when 'ID' then '🇮🇩'
  241. when: 0 when 'PH' then '🇵🇭'
  242. when: 0 when 'VN' then '🇻🇳'
  243. when: 0 when 'ZA' then '🇿🇦'
  244. when: 0 when 'EG' then '🇪🇬'
  245. when: 0 when 'NG' then '🇳🇬'
  246. when: 0 when 'KE' then '🇰🇪'
  247. when: 0 when 'MA' then '🇲🇦'
  248. when: 0 when 'TN' then '🇹🇳'
  249. when: 0 when 'DZ' then '🇩🇿'
  250. when: 0 when 'TR' then '🇹🇷'
  251. when: 0 when 'SA' then '🇸🇦'
  252. when: 0 when 'AE' then '🇦🇪'
  253. when: 0 when 'IL' then '🇮🇱'
  254. when: 0 when 'IR' then '🇮🇷'
  255. when: 0 when 'IQ' then '🇮🇶'
  256. when: 0 when 'PK' then '🇵🇰'
  257. when: 0 when 'BD' then '🇧🇩'
  258. when: 0 when 'LK' then '🇱🇰'
  259. when: 0 when 'NP' then '🇳🇵'
  260. when: 0 when 'BT' then '🇧🇹'
  261. when: 0 when 'MV' then '🇲🇻'
  262. when: 0 when 'AF' then '🇦🇫'
  263. when: 0 when 'UZ' then '🇺🇿'
  264. when: 0 when 'KZ' then '🇰🇿'
  265. when: 0 when 'KG' then '🇰🇬'
  266. when: 0 when 'TJ' then '🇹🇯'
  267. when: 0 when 'TM' then '🇹🇲'
  268. when: 0 when 'MN' then '🇲🇳'
  269. when: 0 when 'MM' then '🇲🇲'
  270. when: 0 when 'LA' then '🇱🇦'
  271. when: 0 when 'KH' then '🇰🇭'
  272. when: 0 when 'BN' then '🇧🇳'
  273. when: 0 when 'TL' then '🇹🇱'
  274. when: 0 when 'FJ' then '🇫🇯'
  275. when: 0 when 'PG' then '🇵🇬'
  276. when: 0 when 'SB' then '🇸🇧'
  277. when: 0 when 'VU' then '🇻🇺'
  278. when: 0 when 'NC' then '🇳🇨'
  279. when: 0 when 'PF' then '🇵🇫'
  280. when: 0 when 'WS' then '🇼🇸'
  281. when: 0 when 'TO' then '🇹🇴'
  282. when: 0 when 'KI' then '🇰🇮'
  283. when: 0 when 'TV' then '🇹🇻'
  284. when: 0 when 'NR' then '🇳🇷'
  285. when: 0 when 'PW' then '🇵🇼'
  286. when: 0 when 'FM' then '🇫🇲'
  287. when: 0 when 'MH' then '🇲🇭'
  288. when: 0 when 'CK' then '🇨🇰'
  289. when: 0 when 'NU' then '🇳🇺'
  290. when: 0 when 'TK' then '🇹🇰'
  291. when: 0 when 'AS' then '🇦🇸'
  292. when: 0 when 'GU' then '🇬🇺'
  293. when: 0 when 'MP' then '🇲🇵'
  294. when: 0 when 'VI' then '🇻🇮'
  295. when: 0 when 'PR' then '🇵🇷'
  296. when: 0 when 'DO' then '🇩🇴'
  297. when: 0 when 'HT' then '🇭🇹'
  298. when: 0 when 'CU' then '🇨🇺'
  299. when: 0 when 'JM' then '🇯🇲'
  300. when: 0 when 'BB' then '🇧🇧'
  301. when: 0 when 'TT' then '🇹🇹'
  302. when: 0 when 'GY' then '🇬🇾'
  303. when: 0 when 'SR' then '🇸🇷'
  304. when: 0 when 'GF' then '🇬🇫'
  305. when: 0 when 'UY' then '🇺🇾'
  306. when: 0 when 'PY' then '🇵🇾'
  307. when: 0 when 'BO' then '🇧🇴'
  308. when: 0 when 'EC' then '🇪🇨'
  309. when: 0 when 'PA' then '🇵🇦'
  310. when: 0 when 'CR' then '🇨🇷'
  311. when: 0 when 'NI' then '🇳🇮'
  312. when: 0 when 'HN' then '🇭🇳'
  313. when: 0 when 'SV' then '🇸🇻'
  314. when: 0 when 'GT' then '🇬🇹'
  315. when: 0 when 'BZ' then '🇧🇿'
  316. when: 0 when 'GY' then '🇬🇾'
  317. when: 0 when 'SR' then '🇸🇷'
  318. when: 0 when 'GF' then '🇬🇫'
  319. when: 0 when 'UY' then '🇺🇾'
  320. when: 0 when 'PY' then '🇵🇾'
  321. when: 0 when 'BO' then '🇧🇴'
  322. when: 0 when 'EC' then '🇪🇨'
  323. when: 0 when 'PA' then '🇵🇦'
  324. when: 0 when 'CR' then '🇨🇷'
  325. when: 0 when 'NI' then '🇳🇮'
  326. when: 0 when 'HN' then '🇭🇳'
  327. when: 0 when 'SV' then '🇸🇻'
  328. when: 0 when 'GT' then '🇬🇹'
  329. else: 0 when 'BZ' then '🇧🇿'
  330. else '🌍'
  331. end
  332. end
  333. 1 def country_name(country_code)
  334. # Return full country name for country code
  335. then: 0 else: 0 when: 0 case country_code&.upcase
  336. when: 0 when 'US' then 'United States'
  337. when: 0 when 'GB' then 'United Kingdom'
  338. when: 0 when 'CA' then 'Canada'
  339. when: 0 when 'AU' then 'Australia'
  340. when: 0 when 'DE' then 'Germany'
  341. when: 0 when 'FR' then 'France'
  342. when: 0 when 'IT' then 'Italy'
  343. when: 0 when 'ES' then 'Spain'
  344. when: 0 when 'NL' then 'Netherlands'
  345. when: 0 when 'SE' then 'Sweden'
  346. when: 0 when 'NO' then 'Norway'
  347. when: 0 when 'DK' then 'Denmark'
  348. when: 0 when 'FI' then 'Finland'
  349. when: 0 when 'JP' then 'Japan'
  350. when: 0 when 'CN' then 'China'
  351. when: 0 when 'IN' then 'India'
  352. when: 0 when 'BR' then 'Brazil'
  353. when: 0 when 'MX' then 'Mexico'
  354. when: 0 when 'AR' then 'Argentina'
  355. when: 0 when 'CL' then 'Chile'
  356. when: 0 when 'CO' then 'Colombia'
  357. when: 0 when 'PE' then 'Peru'
  358. when: 0 when 'VE' then 'Venezuela'
  359. when: 0 when 'RU' then 'Russia'
  360. when: 0 when 'KR' then 'South Korea'
  361. when: 0 when 'TH' then 'Thailand'
  362. when: 0 when 'SG' then 'Singapore'
  363. when: 0 when 'MY' then 'Malaysia'
  364. when: 0 when 'ID' then 'Indonesia'
  365. when: 0 when 'PH' then 'Philippines'
  366. when: 0 when 'VN' then 'Vietnam'
  367. when: 0 when 'ZA' then 'South Africa'
  368. when: 0 when 'EG' then 'Egypt'
  369. when: 0 when 'NG' then 'Nigeria'
  370. when: 0 when 'KE' then 'Kenya'
  371. when: 0 when 'MA' then 'Morocco'
  372. when: 0 when 'TN' then 'Tunisia'
  373. when: 0 when 'DZ' then 'Algeria'
  374. when: 0 when 'TR' then 'Turkey'
  375. when: 0 when 'SA' then 'Saudi Arabia'
  376. when: 0 when 'AE' then 'United Arab Emirates'
  377. when: 0 when 'IL' then 'Israel'
  378. when: 0 when 'IR' then 'Iran'
  379. when: 0 when 'IQ' then 'Iraq'
  380. when: 0 when 'PK' then 'Pakistan'
  381. when: 0 when 'BD' then 'Bangladesh'
  382. when: 0 when 'LK' then 'Sri Lanka'
  383. when: 0 when 'NP' then 'Nepal'
  384. when: 0 when 'BT' then 'Bhutan'
  385. when: 0 when 'MV' then 'Maldives'
  386. when: 0 when 'AF' then 'Afghanistan'
  387. when: 0 when 'UZ' then 'Uzbekistan'
  388. when: 0 when 'KZ' then 'Kazakhstan'
  389. when: 0 when 'KG' then 'Kyrgyzstan'
  390. when: 0 when 'TJ' then 'Tajikistan'
  391. when: 0 when 'TM' then 'Turkmenistan'
  392. when: 0 when 'MN' then 'Mongolia'
  393. when: 0 when 'MM' then 'Myanmar'
  394. when: 0 when 'LA' then 'Laos'
  395. when: 0 when 'KH' then 'Cambodia'
  396. when: 0 when 'BN' then 'Brunei'
  397. when: 0 when 'TL' then 'Timor-Leste'
  398. when: 0 when 'FJ' then 'Fiji'
  399. when: 0 when 'PG' then 'Papua New Guinea'
  400. when: 0 when 'SB' then 'Solomon Islands'
  401. when: 0 when 'VU' then 'Vanuatu'
  402. when: 0 when 'NC' then 'New Caledonia'
  403. when: 0 when 'PF' then 'French Polynesia'
  404. when: 0 when 'WS' then 'Samoa'
  405. when: 0 when 'TO' then 'Tonga'
  406. when: 0 when 'KI' then 'Kiribati'
  407. when: 0 when 'TV' then 'Tuvalu'
  408. when: 0 when 'NR' then 'Nauru'
  409. when: 0 when 'PW' then 'Palau'
  410. when: 0 when 'FM' then 'Micronesia'
  411. when: 0 when 'MH' then 'Marshall Islands'
  412. when: 0 when 'CK' then 'Cook Islands'
  413. when: 0 when 'NU' then 'Niue'
  414. when: 0 when 'TK' then 'Tokelau'
  415. when: 0 when 'AS' then 'American Samoa'
  416. when: 0 when 'GU' then 'Guam'
  417. when: 0 when 'MP' then 'Northern Mariana Islands'
  418. when: 0 when 'VI' then 'U.S. Virgin Islands'
  419. when: 0 when 'PR' then 'Puerto Rico'
  420. when: 0 when 'DO' then 'Dominican Republic'
  421. when: 0 when 'HT' then 'Haiti'
  422. when: 0 when 'CU' then 'Cuba'
  423. when: 0 when 'JM' then 'Jamaica'
  424. when: 0 when 'BB' then 'Barbados'
  425. when: 0 when 'TT' then 'Trinidad and Tobago'
  426. when: 0 when 'GY' then 'Guyana'
  427. when: 0 when 'SR' then 'Suriname'
  428. when: 0 when 'GF' then 'French Guiana'
  429. when: 0 when 'UY' then 'Uruguay'
  430. when: 0 when 'PY' then 'Paraguay'
  431. when: 0 when 'BO' then 'Bolivia'
  432. when: 0 when 'EC' then 'Ecuador'
  433. when: 0 when 'PA' then 'Panama'
  434. when: 0 when 'CR' then 'Costa Rica'
  435. when: 0 when 'NI' then 'Nicaragua'
  436. when: 0 when 'HN' then 'Honduras'
  437. when: 0 when 'SV' then 'El Salvador'
  438. when: 0 when 'GT' then 'Guatemala'
  439. else: 0 when 'BZ' then 'Belize'
  440. else country_code
  441. end
  442. end
  443. 1 def device_icon(device)
  444. then: 0 else: 0 when: 0 case device&.downcase
  445. when: 0 when 'desktop' then '🖥️'
  446. when: 0 when 'mobile' then '📱'
  447. when: 0 when 'tablet' then '📱'
  448. else: 0 when 'phone' then '📱'
  449. else '💻'
  450. end
  451. end
  452. 1 def source_domain(referrer)
  453. then: 0 else: 0 return 'Direct' if referrer.blank?
  454. begin
  455. uri = URI.parse(referrer)
  456. domain = uri.host
  457. when: 0 case domain
  458. when: 0 when /google\./ then 'Google'
  459. when: 0 when /bing\./ then 'Bing'
  460. when: 0 when /yahoo\./ then 'Yahoo'
  461. when: 0 when /duckduckgo\./ then 'DuckDuckGo'
  462. when: 0 when /facebook\./ then 'Facebook'
  463. when: 0 when /twitter\./ then 'Twitter'
  464. when: 0 when /linkedin\./ then 'LinkedIn'
  465. when: 0 when /instagram\./ then 'Instagram'
  466. when: 0 when /youtube\./ then 'YouTube'
  467. when: 0 when /reddit\./ then 'Reddit'
  468. when: 0 when /pinterest\./ then 'Pinterest'
  469. when: 0 when /tumblr\./ then 'Tumblr'
  470. when: 0 when /medium\./ then 'Medium'
  471. when: 0 when /dev\./ then 'Dev.to'
  472. when: 0 when /hashnode\./ then 'Hashnode'
  473. when: 0 when /hackernews\./ then 'Hacker News'
  474. when: 0 when /github\./ then 'GitHub'
  475. else: 0 when /stackoverflow\./ then 'Stack Overflow'
  476. else domain
  477. end
  478. rescue
  479. referrer
  480. end
  481. end
  482. # Check if we're on an admin page
  483. 1 def admin_page?
  484. controller_path.start_with?('admin/')
  485. end
  486. # Format large numbers
  487. 1 def format_number(num)
  488. then: 0 else: 0 return '0' if num.nil? || num.zero?
  489. then: 0 if num >= 1_000_000
  490. else: 0 "#{(num / 1_000_000.0).round(1)}M"
  491. then: 0 elsif num >= 1_000
  492. "#{(num / 1_000.0).round(1)}K"
  493. else: 0 else
  494. num.to_s
  495. end
  496. end
  497. # Format percentage
  498. 1 def format_percentage(num)
  499. then: 0 else: 0 return '0%' if num.nil?
  500. "#{num.round(1)}%"
  501. end
  502. # Get country flag emoji
  503. 1 def country_flag(country_code)
  504. else: 0 then: 0 return '' unless country_code
  505. # Convert country code to flag emoji
  506. country_code.upcase.chars.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
  507. rescue
  508. '🌍'
  509. end
  510. # Get device icon
  511. 1 def device_icon(device)
  512. then: 0 else: 0 case device&.downcase
  513. when: 0 when 'mobile'
  514. '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>'
  515. when: 0 when 'tablet'
  516. '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm4 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>'
  517. else: 0 else
  518. '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd"/></svg>'
  519. end
  520. end
  521. # Get browser icon
  522. 1 def browser_icon(browser)
  523. icons = {
  524. 'chrome' => '🌐',
  525. 'firefox' => '🦊',
  526. 'safari' => '🧭',
  527. 'edge' => '🔷',
  528. 'opera' => '🅾️'
  529. }
  530. then: 0 else: 0 icons[browser&.downcase] || '🌍'
  531. end
  532. end

app/helpers/api/v1/ai_agents_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module Api::V1::AiAgentsHelper
  2. end

app/helpers/api/v1/docs_helper.rb

40.0% lines covered

0.0% branches covered

10 relevant lines. 4 lines covered and 6 lines missed.
5 total branches, 0 branches covered and 5 branches missed.
    
  1. 1 module Api
  2. 1 module V1
  3. 1 module DocsHelper
  4. 1 def method_badge_class(method)
  5. case method.upcase
  6. when: 0 when 'GET'
  7. 'bg-blue-500/10 text-blue-400'
  8. when: 0 when 'POST'
  9. 'bg-green-500/10 text-green-400'
  10. when: 0 when 'PATCH', 'PUT'
  11. 'bg-yellow-500/10 text-yellow-400'
  12. when: 0 when 'DELETE'
  13. 'bg-red-500/10 text-red-400'
  14. else: 0 else
  15. 'bg-gray-500/10 text-gray-400'
  16. end
  17. end
  18. end
  19. end
  20. end

app/helpers/appearance_helper.rb

24.49% lines covered

0.0% branches covered

49 relevant lines. 12 lines covered and 37 lines missed.
18 total branches, 0 branches covered and 18 branches missed.
    
  1. 1 module AppearanceHelper
  2. # Generate dynamic CSS based on appearance settings
  3. 1 def dynamic_appearance_css
  4. color_scheme = SiteSetting.get('color_scheme', 'midnight')
  5. primary_color = SiteSetting.get('primary_color', '#6366F1')
  6. secondary_color = SiteSetting.get('secondary_color', '#8B5CF6')
  7. heading_font = SiteSetting.get('heading_font', 'Inter')
  8. body_font = SiteSetting.get('body_font', 'Inter')
  9. paragraph_font = SiteSetting.get('paragraph_font', 'Inter')
  10. # Color scheme variables
  11. scheme_colors = color_scheme_colors(color_scheme)
  12. # Determine if light theme
  13. is_light_theme = color_scheme == 'amanecer'
  14. # Set text colors based on theme with better contrast
  15. then: 0 else: 0 text_primary = is_light_theme ? '#1a202c' : '#ffffff'
  16. then: 0 else: 0 text_secondary = is_light_theme ? '#2d3748' : '#e8e8e8'
  17. then: 0 else: 0 text_tertiary = is_light_theme ? '#4a5568' : '#a8a8a8'
  18. then: 0 else: 0 text_muted = is_light_theme ? '#718096' : '#6b7280'
  19. then: 0 else: 0 text_placeholder = is_light_theme ? '#a0aec0' : '#4b5563'
  20. <<~CSS
  21. <style id="dynamic-appearance">
  22. :root {
  23. /* Modern Admin Color System */
  24. /* Backgrounds - Layered depth */
  25. --bg-primary: #{scheme_colors[:bg_primary]};
  26. --bg-secondary: #{scheme_colors[:bg_secondary]};
  27. --bg-tertiary: #{scheme_colors[:bg_tertiary]};
  28. --admin-bg-app: #{scheme_colors[:bg_primary]};
  29. --admin-bg-primary: #{scheme_colors[:bg_secondary]};
  30. --admin-bg-secondary: #{scheme_colors[:bg_tertiary]};
  31. --admin-bg-tertiary: #{lighten_color(scheme_colors[:bg_tertiary], 3)};
  32. --admin-bg-elevated: #{lighten_color(scheme_colors[:bg_tertiary], 5)};
  33. /* Borders - Subtle hierarchy */
  34. --border-color: #{scheme_colors[:border_color]};
  35. --admin-border-subtle: #{scheme_colors[:border_color]};
  36. --admin-border: #{lighten_color(scheme_colors[:border_color], 5)};
  37. --admin-border-strong: #{lighten_color(scheme_colors[:border_color], 10)};
  38. /* Text - High contrast */
  39. --text-primary: #{text_primary};
  40. --text-secondary: #{text_secondary};
  41. --text-muted: #{text_muted};
  42. --admin-text-primary: #{text_primary};
  43. --admin-text-secondary: #{text_secondary};
  44. --admin-text-tertiary: #{text_tertiary};
  45. --admin-text-muted: #{text_muted};
  46. --admin-text-placeholder: #{text_placeholder};
  47. /* Brand Colors - Vibrant accents */
  48. --color-primary: #{primary_color};
  49. --color-secondary: #{secondary_color};
  50. --admin-primary: #{primary_color};
  51. --admin-primary-hover: #{darken_color(primary_color, 8)};
  52. --admin-primary-light: #{hex_to_rgba(primary_color, 0.1)};
  53. --admin-secondary: #{secondary_color};
  54. --admin-secondary-hover: #{darken_color(secondary_color, 8)};
  55. --admin-secondary-light: #{hex_to_rgba(secondary_color, 0.1)};
  56. /* Status Colors */
  57. --admin-success: #10b981;
  58. --admin-success-light: rgba(16, 185, 129, 0.1);
  59. --admin-success-border: rgba(16, 185, 129, 0.2);
  60. --admin-warning: #f59e0b;
  61. --admin-warning-light: rgba(245, 158, 11, 0.1);
  62. --admin-warning-border: rgba(245, 158, 11, 0.2);
  63. --admin-error: #ef4444;
  64. --admin-error-light: rgba(239, 68, 68, 0.1);
  65. --admin-error-border: rgba(239, 68, 68, 0.2);
  66. --admin-info: #3b82f6;
  67. --admin-info-light: rgba(59, 130, 246, 0.1);
  68. --admin-info-border: rgba(59, 130, 246, 0.2);
  69. /* Typography */
  70. --font-heading: #{heading_font};
  71. --font-body: #{body_font};
  72. --font-paragraph: #{paragraph_font};
  73. }
  74. /* Apply brand colors */
  75. .bg-indigo-600, .bg-primary {
  76. background-color: var(--color-primary) !important;
  77. }
  78. .text-indigo-600, .text-indigo-400 {
  79. color: var(--color-primary) !important;
  80. }
  81. .border-indigo-500, .focus\\:ring-indigo-500:focus {
  82. border-color: var(--color-primary) !important;
  83. }
  84. .ring-indigo-500 {
  85. --tw-ring-color: var(--color-primary) !important;
  86. }
  87. /* Hover states */
  88. .hover\\:bg-indigo-700:hover {
  89. background-color: #{darken_color(primary_color, 10)} !important;
  90. }
  91. /* Secondary color */
  92. .bg-purple-600 {
  93. background-color: var(--color-secondary) !important;
  94. }
  95. /* Text color overrides for consistency */
  96. .text-white {
  97. color: var(--text-primary) !important;
  98. }
  99. .text-gray-300, .text-gray-400 {
  100. color: var(--text-secondary) !important;
  101. }
  102. .text-gray-500, .text-gray-600 {
  103. color: var(--text-muted) !important;
  104. }
  105. /* Typography */
  106. h1, h2, h3, h4, h5, h6 {
  107. font-family: var(--font-heading), sans-serif !important;
  108. }
  109. body, button, input, select, textarea {
  110. font-family: var(--font-body), sans-serif !important;
  111. }
  112. p, .paragraph {
  113. font-family: var(--font-paragraph), sans-serif !important;
  114. }
  115. /* Color scheme background */
  116. .bg-\\[\\#0a0a0a\\], .bg-\\[\\#111111\\] {
  117. background-color: var(--bg-primary) !important;
  118. }
  119. .bg-\\[\\#1a1a1a\\] {
  120. background-color: var(--bg-secondary) !important;
  121. }
  122. .border-\\[\\#2a2a2a\\] {
  123. border-color: var(--border-color) !important;
  124. }
  125. /* Light theme text colors */
  126. then: 0 #{color_scheme == 'amanecer' ? '
  127. body, .text-white {
  128. color: #1a202c !important;
  129. }
  130. .text-gray-300, .text-gray-400 {
  131. color: #4a5568 !important;
  132. }
  133. .text-gray-500, .text-gray-600 {
  134. color: #718096 !important;
  135. }
  136. h1, h2, h3, h4, h5, h6 {
  137. color: #1a202c !important;
  138. }
  139. input, select, textarea {
  140. color: #1a202c !important;
  141. background-color: #ffffff !important;
  142. }
  143. input::placeholder, textarea::placeholder {
  144. color: #a0aec0 !important;
  145. }
  146. /* Update specific dark text classes for light theme */
  147. .text-emerald-400, .text-green-400 {
  148. color: #10b981 !important;
  149. }
  150. .text-red-400 {
  151. color: #ef4444 !important;
  152. }
  153. .text-blue-400, .text-indigo-400 {
  154. color: #6366f1 !important;
  155. }
  156. .text-yellow-400 {
  157. color: #f59e0b !important;
  158. }
  159. /* Sidebar text */
  160. nav a, nav span {
  161. color: #4a5568 !important;
  162. }
  163. nav a:hover {
  164. color: #1a202c !important;
  165. }
  166. /* Top bar */
  167. header {
  168. border-bottom-color: #e2e8f0 !important;
  169. else: 0 }
  170. ' : ''}
  171. </style>
  172. CSS
  173. .html_safe
  174. end
  175. # Get white label settings
  176. 1 def admin_app_name
  177. SiteSetting.get('admin_app_name', 'RailsPress')
  178. end
  179. 1 def admin_logo_url
  180. SiteSetting.get('admin_logo_url', '')
  181. end
  182. 1 def admin_favicon_url
  183. SiteSetting.get('admin_favicon_url', '')
  184. end
  185. 1 def admin_footer_text
  186. SiteSetting.get('admin_footer_text', 'Powered by RailsPress')
  187. end
  188. 1 def hide_branding?
  189. SiteSetting.get('hide_branding', false) == true || SiteSetting.get('hide_branding', false) == '1'
  190. end
  191. 1 private
  192. 1 def color_scheme_colors(scheme)
  193. case scheme
  194. when: 0 when 'midnight' # New default - Modern, sophisticated
  195. {
  196. bg_primary: '#0f0f0f',
  197. bg_secondary: '#141414',
  198. bg_tertiary: '#1a1a1a',
  199. border_color: '#2f2f2f'
  200. }
  201. when: 0 when 'vallarta' # Blue ocean theme
  202. {
  203. bg_primary: '#0a1628',
  204. bg_secondary: '#0f1e3a',
  205. bg_tertiary: '#1a2947',
  206. border_color: '#2a3f5f'
  207. }
  208. when: 0 when 'amanecer' # Light theme
  209. {
  210. bg_primary: '#ffffff',
  211. bg_secondary: '#f8f9fa',
  212. bg_tertiary: '#f1f3f5',
  213. border_color: '#e9ecef'
  214. }
  215. when: 0 when 'onyx' # Pure black
  216. {
  217. bg_primary: '#000000',
  218. bg_secondary: '#0a0a0a',
  219. bg_tertiary: '#111111',
  220. border_color: '#1a1a1a'
  221. }
  222. when: 0 when 'slate' # Cool gray
  223. {
  224. bg_primary: '#0f172a',
  225. bg_secondary: '#1e293b',
  226. bg_tertiary: '#334155',
  227. border_color: '#475569'
  228. }
  229. else: 0 else # midnight (default)
  230. {
  231. bg_primary: '#0f0f0f',
  232. bg_secondary: '#141414',
  233. bg_tertiary: '#1a1a1a',
  234. border_color: '#2f2f2f'
  235. }
  236. end
  237. end
  238. 1 def darken_color(hex, percent)
  239. hex = hex.delete('#')
  240. rgb = hex.scan(/../).map { |color| color.hex }
  241. rgb = rgb.map { |color| [(color * (100 - percent) / 100).to_i, 0].max }
  242. "#%02x%02x%02x" % rgb
  243. end
  244. 1 def lighten_color(hex, percent)
  245. hex = hex.delete('#')
  246. rgb = hex.scan(/../).map { |color| color.hex }
  247. rgb = rgb.map { |color| [color + (255 - color) * percent / 100, 255].min.to_i }
  248. "#%02x%02x%02x" % rgb
  249. end
  250. 1 def hex_to_rgba(hex, alpha = 1.0)
  251. hex = hex.delete('#')
  252. rgb = hex.scan(/../).map { |color| color.hex }
  253. "rgba(#{rgb[0]}, #{rgb[1]}, #{rgb[2]}, #{alpha})"
  254. end
  255. end

app/helpers/application_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module ApplicationHelper
  2. end

app/helpers/consent_helper.rb

24.72% lines covered

0.0% branches covered

89 relevant lines. 22 lines covered and 67 lines missed.
58 total branches, 0 branches covered and 58 branches missed.
    
  1. 1 module ConsentHelper
  2. # Render consent banner HTML
  3. 1 def render_consent_banner
  4. consent_config = ConsentConfiguration.active.first
  5. else: 0 then: 0 return '' unless consent_config
  6. # Get user's region (simplified for Liquid templates)
  7. region = get_user_region
  8. user_consent = get_user_consent_data
  9. consent_config.generate_banner_html(region, user_consent)
  10. end
  11. # Render consent banner CSS
  12. 1 def render_consent_css
  13. consent_config = ConsentConfiguration.active.first
  14. else: 0 then: 0 return '' unless consent_config
  15. consent_config.generate_banner_css
  16. end
  17. # Render consent-aware pixel code
  18. 1 def render_pixel_with_consent(pixel)
  19. then: 0 else: 0 else: 0 then: 0 return '' unless pixel&.active?
  20. # Get consent configuration
  21. consent_config = ConsentConfiguration.active.first
  22. else: 0 then: 0 return pixel.render_code unless consent_config
  23. # Check if pixel requires consent
  24. required_consent = consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
  25. if required_consent.any?
  26. then: 0 # Pixel requires consent - wrap in consent-aware code
  27. consent_categories = required_consent.join(',')
  28. <<~HTML
  29. <div data-pixel-type="#{pixel.pixel_type}" data-consent-categories="#{consent_categories}" class="consent-pixel" style="display: none;">
  30. #{pixel.render_code}
  31. </div>
  32. HTML
  33. else
  34. else: 0 # Pixel doesn't require consent - render normally
  35. pixel.render_code
  36. end
  37. end
  38. # Render all pixels with consent awareness
  39. 1 def render_all_pixels_with_consent(position = nil)
  40. pixels = Pixel.active
  41. then: 0 else: 0 pixels = pixels.by_position(position) if position
  42. pixels.map { |pixel| render_pixel_with_consent(pixel) }.join.html_safe
  43. end
  44. # Check if user has given consent for a specific category
  45. 1 def user_has_consent?(category)
  46. else: 0 then: 0 return false unless user_signed_in?
  47. then: 0 else: 0 current_user.user_consents.find_by(consent_type: category)&.granted? || false
  48. end
  49. # Check if user has given consent for a pixel type
  50. 1 def user_has_pixel_consent?(pixel_type)
  51. consent_config = ConsentConfiguration.active.first
  52. else: 0 then: 0 return true unless consent_config # If no consent config, allow all
  53. required_categories = consent_config.get_consent_categories_for_pixel(pixel_type)
  54. then: 0 else: 0 return true if required_categories.empty? # No consent required
  55. required_categories.all? { |category| user_has_consent?(category) }
  56. end
  57. # Get consent status for current user
  58. 1 def user_consent_status
  59. else: 0 then: 0 return {} unless user_signed_in?
  60. current_user.user_consents.index_by(&:consent_type).transform_values do |consent|
  61. {
  62. granted: consent.granted?,
  63. granted_at: consent.granted_at,
  64. withdrawn_at: consent.withdrawn_at
  65. }
  66. end
  67. end
  68. # Render consent management link
  69. 1 def consent_management_link(text = 'Manage Cookie Preferences', css_class = '')
  70. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  71. link_to text, '#',
  72. class: "consent-management-link #{css_class}",
  73. onclick: 'ConsentManager.showPreferencesModal(); return false;'
  74. end
  75. # Render consent status indicator
  76. 1 def consent_status_indicator(category)
  77. else: 0 then: 0 return '' unless user_signed_in?
  78. consent = current_user.user_consents.find_by(consent_type: category)
  79. else: 0 then: 0 return '' unless consent
  80. then: 0 else: 0 status_class = consent.granted? ? 'consent-granted' : 'consent-withdrawn'
  81. then: 0 else: 0 status_text = consent.granted? ? 'Granted' : 'Withdrawn'
  82. content_tag :span, status_text, class: "consent-status #{status_class}"
  83. end
  84. # Render consent banner for specific region
  85. 1 def render_region_specific_banner(region)
  86. consent_config = ConsentConfiguration.active.first
  87. else: 0 then: 0 return '' unless consent_config
  88. # Check if banner should be shown for this region
  89. else: 0 then: 0 return '' unless consent_config.should_show_banner?(region)
  90. consent_config.generate_banner_html(region)
  91. end
  92. # Get consent configuration for JavaScript
  93. 1 def consent_config_json
  94. consent_config = ConsentConfiguration.active.first
  95. else: 0 then: 0 return '{}' unless consent_config
  96. {
  97. consent_categories: consent_config.consent_categories_with_defaults,
  98. banner_settings: consent_config.banner_settings_with_defaults,
  99. geolocation_settings: consent_config.geolocation_settings_with_defaults,
  100. pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
  101. version: consent_config.version || '1.0'
  102. }.to_json
  103. end
  104. # Render consent banner initialization script
  105. 1 def consent_banner_script
  106. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  107. <<~HTML
  108. <script>
  109. document.addEventListener('DOMContentLoaded', function() {
  110. // Initialize consent manager with configuration
  111. if (typeof ConsentManager !== 'undefined') {
  112. window.consentManager = new ConsentManager({
  113. config: #{consent_config_json},
  114. debug: #{Rails.env.development?}
  115. });
  116. }
  117. });
  118. </script>
  119. HTML
  120. end
  121. # Render consent banner CSS and HTML
  122. 1 def consent_banner_assets
  123. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  124. css = render_consent_css
  125. html = render_consent_banner
  126. script = consent_banner_script
  127. <<~HTML
  128. <style>
  129. #{css}
  130. </style>
  131. #{html}
  132. #{script}
  133. HTML
  134. end
  135. # Check if consent banner should be shown
  136. 1 def should_show_consent_banner?
  137. consent_config = ConsentConfiguration.active.first
  138. else: 0 then: 0 return false unless consent_config
  139. # Check if user has already given consent
  140. then: 0 else: 0 return false if user_signed_in? && current_user.user_consents.granted.exists?
  141. # Check if banner is enabled
  142. consent_config.banner_settings_with_defaults['enabled']
  143. end
  144. # Render consent banner only if needed
  145. 1 def render_consent_banner_if_needed
  146. else: 0 then: 0 return '' unless should_show_consent_banner?
  147. consent_banner_assets
  148. end
  149. # Get user's consent data for JavaScript
  150. 1 def user_consent_json
  151. else: 0 then: 0 return '{}' unless user_signed_in?
  152. user_consent_status.to_json
  153. end
  154. # Render user consent data script
  155. 1 def user_consent_script
  156. else: 0 then: 0 return '' unless user_signed_in?
  157. <<~HTML
  158. <script>
  159. window.userConsentData = #{user_consent_json};
  160. </script>
  161. HTML
  162. end
  163. # Render consent-aware pixel loading script
  164. 1 def consent_pixel_script
  165. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  166. <<~HTML
  167. <script>
  168. // Override pixel loading to respect consent
  169. document.addEventListener('DOMContentLoaded', function() {
  170. // Find all consent-aware pixels
  171. const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
  172. consentPixels.forEach(function(pixel) {
  173. const pixelType = pixel.dataset.pixelType;
  174. const requiredCategories = pixel.dataset.consentCategories.split(',');
  175. // Check if user has required consent
  176. let hasConsent = true;
  177. if (window.userConsentData) {
  178. hasConsent = requiredCategories.every(function(category) {
  179. return window.userConsentData[category] && window.userConsentData[category].granted;
  180. });
  181. }
  182. if (hasConsent) {
  183. // Load the pixel
  184. pixel.style.display = '';
  185. pixel.classList.remove('consent-disabled');
  186. } else {
  187. // Keep pixel hidden
  188. pixel.style.display = 'none';
  189. pixel.classList.add('consent-disabled');
  190. }
  191. });
  192. });
  193. </script>
  194. HTML
  195. end
  196. 1 private
  197. 1 def get_user_region
  198. # Simplified region detection for Liquid templates
  199. # In a real implementation, this would use the same logic as the API
  200. request.remote_ip || 'unknown'
  201. end
  202. 1 def get_user_consent_data
  203. else: 0 then: 0 return [] unless user_signed_in?
  204. current_user.user_consents.map do |consent|
  205. {
  206. consent_type: consent.consent_type,
  207. granted: consent.granted?
  208. }
  209. end
  210. end
  211. end

app/helpers/editor_helper.rb

25.0% lines covered

0.0% branches covered

24 relevant lines. 6 lines covered and 18 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. 1 module EditorHelper
  2. # Main method to render the content editor based on user preference
  3. 1 def render_content_editor(form, field_name, content: nil, options: {})
  4. # Get content from form object if not provided
  5. then: 0 else: 0 content = form.object.send(field_name) if content.nil? && form.object.respond_to?(field_name)
  6. # Get user's preferred editor or default to blocknote
  7. then: 0 else: 0 editor_type = current_user&.preferred_editor || 'blocknote'
  8. placeholder = options[:placeholder] || 'Start writing...'
  9. # Render the reusable content editor partial
  10. render partial: 'shared/content_editor', locals: {
  11. form: form,
  12. content: content,
  13. field_name: field_name,
  14. placeholder: placeholder,
  15. editor_type: editor_type
  16. }
  17. end
  18. # Editor preference options for settings
  19. 1 def editor_preference_options
  20. [
  21. ['BlockNote - Modern Block Editor (Default)', 'blocknote'],
  22. ['Trix - ActionText Rich Text', 'trix'],
  23. ['CKEditor - Classic WYSIWYG', 'ckeditor'],
  24. ['Editor.js - JSON-based Editor', 'editorjs']
  25. ]
  26. end
  27. # Get display name for editor type
  28. 1 def editor_display_name(editor_type)
  29. case editor_type
  30. when: 0 when 'blocknote'
  31. 'BlockNote'
  32. when: 0 when 'trix'
  33. 'Trix (ActionText)'
  34. when: 0 when 'ckeditor'
  35. 'CKEditor'
  36. when: 0 when 'editorjs'
  37. 'Editor.js'
  38. else: 0 else
  39. editor_type.titleize
  40. end
  41. end
  42. # Check if user has a specific editor preference set
  43. 1 def user_has_editor_preference?
  44. then: 0 else: 0 current_user&.editor_preference.present?
  45. end
  46. # Get editor icon for UI
  47. 1 def editor_icon(editor_type)
  48. case editor_type
  49. when: 0 when 'blocknote'
  50. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  51. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
  52. </svg>'.html_safe
  53. when: 0 when 'trix'
  54. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  55. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
  56. </svg>'.html_safe
  57. when: 0 when 'ckeditor'
  58. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  59. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
  60. </svg>'.html_safe
  61. when: 0 when 'editorjs'
  62. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  63. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
  64. </svg>'.html_safe
  65. else: 0 else
  66. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  67. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
  68. </svg>'.html_safe
  69. end
  70. end
  71. end

app/helpers/home_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module HomeHelper
  2. end

app/helpers/image_optimization_helper.rb

21.62% lines covered

0.0% branches covered

37 relevant lines. 8 lines covered and 29 lines missed.
21 total branches, 0 branches covered and 21 branches missed.
    
  1. 1 module ImageOptimizationHelper
  2. # Generate picture element with multiple formats for optimal browser support
  3. 1 def optimized_image_tag(upload, options = {})
  4. else: 0 then: 0 return image_tag(upload.url, options) unless upload.image?
  5. # Build picture element with fallbacks
  6. content_tag :picture do
  7. # AVIF variant (best compression)
  8. then: 0 else: 0 if upload.has_variant?('avif')
  9. concat content_tag(:source, '',
  10. srcset: upload.avif_url,
  11. type: 'image/avif'
  12. )
  13. end
  14. # WebP variant (good compression, wide support)
  15. then: 0 else: 0 if upload.has_variant?('webp')
  16. concat content_tag(:source, '',
  17. srcset: upload.webp_url,
  18. type: 'image/webp'
  19. )
  20. end
  21. # Original image as fallback
  22. concat image_tag(upload.url, options)
  23. end
  24. end
  25. # Generate responsive image with multiple sizes
  26. 1 def responsive_image_tag(upload, options = {})
  27. else: 0 then: 0 return optimized_image_tag(upload, options) unless upload.image?
  28. sizes = options.delete(:sizes) || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
  29. content_tag :picture do
  30. # AVIF variants for different sizes
  31. then: 0 else: 0 if upload.has_variant?('avif')
  32. concat content_tag(:source, '',
  33. srcset: generate_srcset(upload, 'avif'),
  34. sizes: sizes,
  35. type: 'image/avif'
  36. )
  37. end
  38. # WebP variants for different sizes
  39. then: 0 else: 0 if upload.has_variant?('webp')
  40. concat content_tag(:source, '',
  41. srcset: generate_srcset(upload, 'webp'),
  42. sizes: sizes,
  43. type: 'image/webp'
  44. )
  45. end
  46. # Original image variants for different sizes
  47. concat image_tag(upload.url,
  48. options.merge(
  49. srcset: generate_srcset(upload, 'original'),
  50. sizes: sizes
  51. )
  52. )
  53. end
  54. end
  55. # Generate CSS for background image with format fallbacks
  56. 1 def optimized_background_image_css(upload)
  57. else: 0 then: 0 return "background-image: url('#{upload.url}');" unless upload.image?
  58. css_parts = []
  59. # Add AVIF variant if available
  60. then: 0 else: 0 if upload.has_variant?('avif')
  61. css_parts << "background-image: url('#{upload.avif_url}');"
  62. end
  63. # Add WebP variant if available
  64. then: 0 else: 0 if upload.has_variant?('webp')
  65. css_parts << "background-image: url('#{upload.webp_url}');"
  66. end
  67. # Add original as fallback
  68. css_parts << "background-image: url('#{upload.url}');"
  69. css_parts.join(' ')
  70. end
  71. # Check if browser supports modern image formats
  72. 1 def supports_avif?
  73. # This would typically be detected via JavaScript
  74. # For now, we'll assume modern browsers support it
  75. true
  76. end
  77. 1 def supports_webp?
  78. # This would typically be detected via JavaScript
  79. # For now, we'll assume most browsers support it
  80. true
  81. end
  82. 1 private
  83. 1 def generate_srcset(upload, format)
  84. # This would generate different sizes of the image
  85. # For now, we'll return the single variant URL
  86. case format
  87. when: 0 when 'avif'
  88. upload.avif_url
  89. when: 0 when 'webp'
  90. upload.webp_url
  91. else: 0 else
  92. upload.url
  93. end
  94. end
  95. end

app/helpers/monaco_helper.rb

33.33% lines covered

0.0% branches covered

18 relevant lines. 6 lines covered and 12 lines missed.
7 total branches, 0 branches covered and 7 branches missed.
    
  1. 1 module MonacoHelper
  2. # Monaco Editor theme mappings based on admin themes
  3. ADMIN_THEME_MONACO_MAPPINGS = {
  4. 1 'onyx' => 'vs-dark',
  5. 'vallarta' => 'vs-dark-blue',
  6. 'amanecer' => 'vs',
  7. 'default' => 'vs'
  8. }.freeze
  9. # Get the appropriate Monaco theme based on user preference and admin theme
  10. 1 def monaco_theme_for_user(user = current_user, admin_theme = 'default')
  11. then: 0 else: 0 return 'vs' if user.nil?
  12. case user.preferred_monaco_theme
  13. when 'auto'
  14. when: 0 # Auto-detect based on admin theme
  15. ADMIN_THEME_MONACO_MAPPINGS[admin_theme.downcase] || 'vs'
  16. when: 0 when 'dark'
  17. 'vs-dark'
  18. when: 0 when 'light'
  19. 'vs'
  20. when: 0 when 'blue'
  21. 'vs-dark-blue'
  22. else: 0 else
  23. 'vs'
  24. end
  25. end
  26. # Get Monaco theme options for dropdown
  27. 1 def monaco_theme_options
  28. [
  29. ['Auto (Follow Admin Theme)', 'auto'],
  30. ['Dark', 'dark'],
  31. ['Light', 'light'],
  32. ['Blue', 'blue']
  33. ]
  34. end
  35. # Get current admin theme (this would need to be implemented based on your admin theme system)
  36. 1 def current_admin_theme
  37. # This should return the current admin theme name
  38. # For now, we'll use a default or get it from session/cookie
  39. session[:admin_theme] || 'default'
  40. end
  41. # Generate Monaco Editor configuration
  42. 1 def monaco_editor_config(options = {})
  43. theme = monaco_theme_for_user(current_user, current_admin_theme)
  44. default_config = {
  45. theme: theme,
  46. automaticLayout: true,
  47. fontSize: 14,
  48. lineNumbers: 'on',
  49. minimap: { enabled: true },
  50. scrollBeyondLastLine: false,
  51. wordWrap: 'on',
  52. tabSize: 2,
  53. insertSpaces: true,
  54. formatOnPaste: true,
  55. formatOnType: true,
  56. fixedOverflowWidgets: true,
  57. renderLineHighlight: 'line',
  58. cursorStyle: 'line',
  59. cursorBlinking: 'blink'
  60. }
  61. default_config.merge(options)
  62. end
  63. end

app/helpers/pages_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module PagesHelper
  2. end

app/helpers/pixels_helper.rb

23.53% lines covered

0.0% branches covered

17 relevant lines. 4 lines covered and 13 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module PixelsHelper
  3. # Render all active pixels for a specific position
  4. #
  5. # @param position [Symbol] The position (:head, :body_start, :body_end)
  6. # @return [String] Rendered HTML
  7. 1 def render_pixels(position)
  8. then: 0 else: 0 return '' if admin_page?
  9. pixels = Pixel.active.by_position(position).ordered
  10. then: 0 else: 0 return '' if pixels.empty?
  11. output = []
  12. output << "<!-- RailsPress Tracking Pixels - #{position.to_s.titleize} -->"
  13. pixels.each do |pixel|
  14. else: 0 then: 0 next unless pixel.configured?
  15. output << "<!-- #{pixel.name} (#{pixel.pixel_type.titleize}) -->"
  16. output << pixel.render_code
  17. end
  18. output << "<!-- End RailsPress Tracking Pixels -->"
  19. output.join("\n").html_safe
  20. end
  21. # Check if we're on an admin page
  22. 1 def admin_page?
  23. controller_path.start_with?('admin/')
  24. end
  25. # Get pixel statistics
  26. 1 def pixel_stats
  27. {
  28. total: Pixel.count,
  29. active: Pixel.active.count,
  30. by_position: Pixel.active.group(:position).count
  31. }
  32. end
  33. end

app/helpers/plugin_blocks_helper.rb

22.22% lines covered

0.0% branches covered

18 relevant lines. 4 lines covered and 14 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module PluginBlocksHelper
  3. # Render plugin blocks for a specific location and position
  4. #
  5. # @param location [Symbol] The location (e.g., :post, :page)
  6. # @param position [Symbol] The position (e.g., :sidebar, :main)
  7. # @param context [Hash] Context to pass to blocks
  8. # @return [String] Rendered HTML
  9. 1 def render_plugin_blocks(location, position: :sidebar, **context)
  10. # Ensure we always have a hash to work with
  11. else: 0 then: 0 context = {} unless context.is_a?(Hash)
  12. full_context = context.merge(
  13. current_user: current_user,
  14. controller: controller,
  15. action_name: action_name
  16. )
  17. result = Railspress::PluginBlocks.render_all(
  18. location,
  19. position: position,
  20. context: full_context,
  21. view_context: self
  22. )
  23. # Ensure we return a safe string
  24. then: 0 else: 0 result.is_a?(String) ? result.html_safe : ''
  25. rescue => e
  26. Rails.logger.error("Error rendering plugin blocks: #{e.message}")
  27. Rails.logger.error(e.backtrace.join("\n"))
  28. then: 0 if Rails.env.development?
  29. content_tag(:div, class: 'p-4 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-sm') do
  30. "Error rendering plugin blocks: #{e.message}"
  31. end
  32. else: 0 else
  33. ''
  34. end
  35. end
  36. # Check if there are any blocks for a location/position
  37. #
  38. # @param location [Symbol] The location
  39. # @param position [Symbol] The position
  40. # @param context [Hash] Context for can_render checks
  41. # @return [Boolean] True if blocks exist
  42. 1 def plugin_blocks_present?(location, position: :sidebar, **context)
  43. full_context = context.merge(
  44. current_user: current_user,
  45. controller: controller,
  46. action_name: action_name
  47. )
  48. Railspress::PluginBlocks.for_location(
  49. location,
  50. position: position,
  51. context: full_context
  52. ).any?
  53. end
  54. # Render a single plugin block
  55. #
  56. # @param key [Symbol] The block key
  57. # @param context [Hash] Context to pass to the block
  58. # @return [String] Rendered HTML
  59. 1 def render_plugin_block(key, **context)
  60. full_context = context.merge(
  61. current_user: current_user,
  62. controller: controller,
  63. action_name: action_name
  64. )
  65. Railspress::PluginBlocks.render(
  66. key,
  67. context: full_context,
  68. view_context: self
  69. )
  70. end
  71. end

app/helpers/plugin_settings_helper.rb

17.33% lines covered

0.0% branches covered

75 relevant lines. 13 lines covered and 62 lines missed.
29 total branches, 0 branches covered and 29 branches missed.
    
  1. 1 module PluginSettingsHelper
  2. # Render plugin settings form from schema
  3. 1 def render_plugin_settings_form(plugin, settings_values = {})
  4. else: 0 then: 0 return content_tag(:div, "No plugin instance provided", class: 'text-red-500') unless plugin
  5. else: 0 then: 0 return content_tag(:div, "Plugin has no settings", class: 'text-gray-500') unless plugin.has_settings?
  6. schema = plugin.settings_schema
  7. # Since the schema is flat, we'll render all settings in one group
  8. # In the future, we could enhance the DSL to support proper grouping
  9. content_tag(:div, class: 'space-y-8') do
  10. render_settings_group('General', schema, settings_values, plugin.name)
  11. end
  12. end
  13. # Render a single settings group
  14. 1 def render_settings_group(group_name, group_settings, settings_values, plugin_name)
  15. # Build the content manually instead of using content_tag with concat
  16. fields_html = group_settings.map do |setting|
  17. render_settings_field(setting, settings_values[setting[:key]], plugin_name)
  18. end.join.html_safe
  19. content_tag(:div, class: 'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6') do
  20. content_tag(:h2, group_name, class: 'text-xl font-semibold text-gray-900 dark:text-white mb-6') +
  21. content_tag(:div, fields_html, class: 'space-y-6')
  22. end
  23. end
  24. # Render a single settings field
  25. 1 def render_settings_field(field, value, plugin_name)
  26. value ||= field[:default]
  27. field_name = "settings[#{field[:key]}]"
  28. field_id = "#{plugin_name.underscore}_#{field[:key]}"
  29. content_tag(:div, class: 'space-y-2') do
  30. case field[:type]
  31. when: 0 when 'text', 'email', 'url', 'number'
  32. render_text_field(field, value, field_name, field_id)
  33. when: 0 when 'textarea'
  34. render_textarea_field(field, value, field_name, field_id)
  35. when: 0 when 'checkbox'
  36. render_checkbox_field(field, value, field_name, field_id)
  37. when: 0 when 'select'
  38. render_select_field(field, value, field_name, field_id)
  39. when: 0 when 'radio'
  40. render_radio_field(field, value, field_name, field_id)
  41. when: 0 when 'color'
  42. render_color_field(field, value, field_name, field_id)
  43. when: 0 when 'wysiwyg'
  44. render_wysiwyg_field(field, value, field_name, field_id)
  45. when: 0 when 'code'
  46. render_code_field(field, value, field_name, field_id)
  47. else: 0 else
  48. render_text_field(field, value, field_name, field_id)
  49. end
  50. end
  51. end
  52. 1 private
  53. 1 def render_text_field(field, value, field_name, field_id)
  54. label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  55. input_html = text_field_tag(field_name, value,
  56. id: field_id,
  57. type: field[:type],
  58. required: field[:required],
  59. placeholder: field[:placeholder],
  60. class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
  61. )
  62. then: 0 else: 0 description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
  63. content_tag(:div, label_html + input_html + description_html)
  64. end
  65. 1 def render_textarea_field(field, value, field_name, field_id)
  66. label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  67. textarea_html = text_area_tag(field_name, value,
  68. id: field_id,
  69. rows: field[:rows] || 4,
  70. required: field[:required],
  71. placeholder: field[:placeholder],
  72. class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
  73. )
  74. then: 0 else: 0 description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
  75. content_tag(:div, label_html + textarea_html + description_html)
  76. end
  77. 1 def render_checkbox_field(field, value, field_name, field_id)
  78. checkbox_html = check_box_tag(field_name, '1', value.to_s == '1' || value == true,
  79. id: field_id,
  80. class: 'w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 mt-1'
  81. )
  82. label_html = label_tag(field_id, field[:label], class: 'text-sm font-medium text-gray-700 dark:text-gray-300')
  83. then: 0 else: 0 description_html = field[:description] ? content_tag(:p, field[:description], class: 'text-sm text-gray-500 dark:text-gray-400') : ''
  84. content_tag(:div, class: 'flex items-start') do
  85. checkbox_html + content_tag(:div, label_html + description_html, class: 'ml-3')
  86. end
  87. end
  88. 1 def render_select_field(field, value, field_name, field_id)
  89. label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  90. select_html = select_tag(field_name, options_for_select(field[:options], value),
  91. id: field_id,
  92. required: field[:required],
  93. class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
  94. )
  95. then: 0 else: 0 description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
  96. content_tag(:div, label_html + select_html + description_html)
  97. end
  98. 1 def render_radio_field(field, value, field_name, field_id)
  99. content_tag(:div) do
  100. concat label_tag(nil, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3')
  101. concat content_tag(:div, class: 'space-y-2') do
  102. field[:options].map.with_index do |(choice_label, choice_value), index|
  103. content_tag(:div, class: 'flex items-center') do
  104. concat radio_button_tag(field_name, choice_value, value == choice_value,
  105. id: "#{field_id}_#{index}",
  106. class: 'w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500'
  107. )
  108. concat label_tag("#{field_id}_#{index}", choice_label, class: 'ml-2 text-sm text-gray-700 dark:text-gray-300')
  109. end
  110. end.join.html_safe
  111. end
  112. then: 0 else: 0 concat content_tag(:p, field[:description], class: 'mt-2 text-sm text-gray-500 dark:text-gray-400') if field[:description]
  113. end
  114. end
  115. 1 def render_color_field(field, value, field_name, field_id)
  116. content_tag(:div) do
  117. concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  118. concat content_tag(:div, class: 'flex items-center gap-3') do
  119. concat color_field_tag(field_name, value || '#000000',
  120. id: field_id,
  121. class: 'h-10 w-20 rounded border border-gray-300'
  122. )
  123. concat text_field_tag("#{field_name}_text", value || '#000000',
  124. class: 'flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white font-mono text-sm',
  125. onchange: "document.getElementById('#{field_id}').value = this.value"
  126. )
  127. end
  128. then: 0 else: 0 concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
  129. end
  130. end
  131. 1 def render_wysiwyg_field(field, value, field_name, field_id)
  132. content_tag(:div) do
  133. concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  134. concat text_area_tag(field_name, value,
  135. id: field_id,
  136. rows: 8,
  137. class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white',
  138. data: { controller: 'trix' }
  139. )
  140. then: 0 else: 0 concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
  141. end
  142. end
  143. 1 def render_code_field(field, value, field_name, field_id)
  144. content_tag(:div) do
  145. concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
  146. concat text_area_tag(field_name, value,
  147. id: field_id,
  148. rows: 12,
  149. class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-900 dark:text-green-400 font-mono text-sm',
  150. spellcheck: 'false'
  151. )
  152. then: 0 else: 0 concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
  153. end
  154. end
  155. end

app/helpers/posts_helper.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 module PostsHelper
  2. end

app/helpers/seo_helper.rb

11.11% lines covered

0.0% branches covered

36 relevant lines. 4 lines covered and 32 lines missed.
18 total branches, 0 branches covered and 18 branches missed.
    
  1. 1 module SeoHelper
  2. # Render complete SEO meta tags for a post or page
  3. 1 def render_seo_tags(resource)
  4. else: 0 then: 0 return '' unless resource.respond_to?(:seo_title)
  5. tags = []
  6. # Basic meta tags
  7. then: 0 else: 0 tags << tag.meta(name: 'description', content: resource.seo_description) if resource.seo_description
  8. then: 0 else: 0 tags << tag.meta(name: 'keywords', content: resource.meta_keywords) if resource.meta_keywords
  9. tags << tag.meta(name: 'robots', content: resource.seo_robots)
  10. tags << tag.link(rel: 'canonical', href: resource.seo_canonical_url)
  11. # Open Graph tags
  12. tags << tag.meta(property: 'og:title', content: resource.seo_og_title)
  13. tags << tag.meta(property: 'og:description', content: resource.seo_og_description)
  14. tags << tag.meta(property: 'og:type', content: 'article')
  15. tags << tag.meta(property: 'og:url', content: resource.seo_canonical_url)
  16. then: 0 else: 0 if resource.seo_og_image.present?
  17. tags << tag.meta(property: 'og:image', content: resource.seo_og_image)
  18. tags << tag.meta(property: 'og:image:alt', content: resource.title)
  19. end
  20. then: 0 else: 0 if resource.respond_to?(:published_at) && resource.published_at
  21. tags << tag.meta(property: 'article:published_time', content: resource.published_at.iso8601)
  22. tags << tag.meta(property: 'article:modified_time', content: resource.updated_at.iso8601)
  23. end
  24. then: 0 else: 0 if resource.respond_to?(:user) && resource.user
  25. tags << tag.meta(property: 'article:author', content: resource.user.email)
  26. end
  27. # Twitter Card tags
  28. tags << tag.meta(name: 'twitter:card', content: resource.twitter_card)
  29. tags << tag.meta(name: 'twitter:title', content: resource.seo_twitter_title)
  30. tags << tag.meta(name: 'twitter:description', content: resource.seo_twitter_description)
  31. then: 0 else: 0 if resource.seo_twitter_image.present?
  32. tags << tag.meta(name: 'twitter:image', content: resource.seo_twitter_image)
  33. end
  34. safe_join(tags, "\n")
  35. end
  36. # Render structured data (Schema.org JSON-LD)
  37. 1 def render_structured_data(resource)
  38. else: 0 then: 0 return '' unless resource.respond_to?(:structured_data)
  39. content_tag(:script, type: 'application/ld+json') do
  40. resource.structured_data.to_json.html_safe
  41. end
  42. end
  43. # Generate meta title with site name
  44. 1 def seo_page_title(resource_title = nil, options = {})
  45. site_name = SiteSetting.get('site_title', 'RailsPress')
  46. separator = options[:separator] || '|'
  47. then: 0 if resource_title
  48. "#{resource_title} #{separator} #{site_name}"
  49. else: 0 else
  50. site_name
  51. end
  52. end
  53. end

app/helpers/status_helper.rb

36.36% lines covered

100.0% branches covered

22 relevant lines. 8 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module StatusHelper
  3. # Get status badge CSS classes
  4. 1 def status_badge_class(status)
  5. badge_classes = {
  6. 'draft' => 'bg-gray-100 text-gray-800',
  7. 'published' => 'bg-green-100 text-green-800',
  8. 'scheduled' => 'bg-blue-100 text-blue-800',
  9. 'pending_review' => 'bg-yellow-100 text-yellow-800',
  10. 'private_post' => 'bg-purple-100 text-purple-800',
  11. 'private_page' => 'bg-purple-100 text-purple-800',
  12. 'trash' => 'bg-red-100 text-red-800'
  13. }
  14. badge_classes[status.to_s] || 'bg-gray-100 text-gray-800'
  15. end
  16. # Get status badge HTML for posts/pages
  17. 1 def status_badge(record)
  18. status = record.status
  19. badge_classes = {
  20. 'draft' => 'bg-gray-500/10 text-gray-400 border-gray-500/20',
  21. 'published' => 'bg-green-500/10 text-green-400 border-green-500/20',
  22. 'scheduled' => 'bg-blue-500/10 text-blue-400 border-blue-500/20',
  23. 'pending_review' => 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
  24. 'private_post' => 'bg-purple-500/10 text-purple-400 border-purple-500/20',
  25. 'private_page' => 'bg-purple-500/10 text-purple-400 border-purple-500/20',
  26. 'trash' => 'bg-red-500/10 text-red-400 border-red-500/20'
  27. }
  28. status_labels = {
  29. 'draft' => 'Draft',
  30. 'published' => 'Published',
  31. 'scheduled' => 'Scheduled',
  32. 'pending_review' => 'Pending Review',
  33. 'private_post' => 'Private',
  34. 'private_page' => 'Private',
  35. 'trash' => 'Trashed'
  36. }
  37. classes = badge_classes[status] || 'bg-gray-500/10 text-gray-400'
  38. label = status_labels[status] || status.titleize
  39. content_tag(:span, label, class: "px-2 py-1 text-xs font-medium rounded border #{classes}")
  40. end
  41. # Get status icon
  42. 1 def status_icon(status)
  43. icons = {
  44. 'draft' => '📝',
  45. 'published' => '✅',
  46. 'scheduled' => '⏰',
  47. 'pending_review' => '👀',
  48. 'private_post' => '🔒',
  49. 'private_page' => '🔒',
  50. 'trash' => '🗑️'
  51. }
  52. icons[status.to_s] || '📄'
  53. end
  54. # Get all statuses for filter dropdown
  55. 1 def post_statuses_for_select
  56. [
  57. ['All Statuses', ''],
  58. ['Draft', 'draft'],
  59. ['Published', 'published'],
  60. ['Scheduled', 'scheduled'],
  61. ['Pending Review', 'pending_review'],
  62. ['Private', 'private_post']
  63. ]
  64. end
  65. 1 def page_statuses_for_select
  66. [
  67. ['All Statuses', ''],
  68. ['Draft', 'draft'],
  69. ['Published', 'published'],
  70. ['Scheduled', 'scheduled'],
  71. ['Pending Review', 'pending_review'],
  72. ['Private', 'private_page']
  73. ]
  74. end
  75. # Get status counts for dashboard
  76. 1 def post_status_counts
  77. {
  78. total: Post.not_trashed.count,
  79. draft: Post.draft_status.count,
  80. published: Post.published_status.count,
  81. scheduled: Post.scheduled_status.count,
  82. pending: Post.pending_review_status.count,
  83. private: Post.private_post_status.count,
  84. trash: Post.trash_status.count
  85. }
  86. end
  87. 1 def page_status_counts
  88. {
  89. total: Page.not_trashed.count,
  90. draft: Page.draft_status.count,
  91. published: Page.published_status.count,
  92. scheduled: Page.scheduled_status.count,
  93. pending: Page.pending_review_status.count,
  94. private: Page.private_page_status.count,
  95. trash: Page.trash_status.count
  96. }
  97. end
  98. end

app/helpers/taxonomy_helper.rb

27.5% lines covered

0.0% branches covered

80 relevant lines. 22 lines covered and 58 lines missed.
42 total branches, 0 branches covered and 42 branches missed.
    
  1. 1 module TaxonomyHelper
  2. # Get category taxonomy
  3. 1 def category_taxonomy
  4. @category_taxonomy ||= Taxonomy.find_by(slug: 'category')
  5. end
  6. # Get tag taxonomy
  7. 1 def tag_taxonomy
  8. @tag_taxonomy ||= Taxonomy.find_by(slug: 'tag')
  9. end
  10. # Get post format taxonomy
  11. 1 def post_format_taxonomy
  12. @post_format_taxonomy ||= Taxonomy.find_by(slug: 'post_format')
  13. end
  14. # Get all categories
  15. 1 def all_categories
  16. then: 0 else: 0 then: 0 else: 0 category_taxonomy&.terms&.order(:name) || Term.none
  17. end
  18. # Get all tags
  19. 1 def all_tags
  20. then: 0 else: 0 then: 0 else: 0 tag_taxonomy&.terms&.order(:name) || Term.none
  21. end
  22. # Get categories for a post
  23. 1 def post_categories(post)
  24. else: 0 then: 0 return [] unless category_taxonomy
  25. post.terms.where(taxonomy: category_taxonomy)
  26. end
  27. # Get tags for a post
  28. 1 def post_tags(post)
  29. else: 0 then: 0 return [] unless tag_taxonomy
  30. post.terms.where(taxonomy: tag_taxonomy)
  31. end
  32. # Get category names for a post
  33. 1 def post_category_names(post)
  34. post_categories(post).pluck(:name)
  35. end
  36. # Get tag names for a post
  37. 1 def post_tag_names(post)
  38. post_tags(post).pluck(:name)
  39. end
  40. # Category link
  41. 1 def category_link(term, options = {})
  42. else: 0 then: 0 return unless term
  43. css_class = options[:class] || 'category-link'
  44. link_to term.name, "/blog/category/#{term.slug}", class: css_class
  45. end
  46. # Tag link
  47. 1 def tag_link(term, options = {})
  48. else: 0 then: 0 return unless term
  49. css_class = options[:class] || 'tag-link'
  50. link_to term.name, "/blog/tag/#{term.slug}", class: css_class
  51. end
  52. # Render category list for post
  53. 1 def render_post_categories(post, options = {})
  54. categories = post_categories(post)
  55. then: 0 else: 0 return '' if categories.empty?
  56. separator = options[:separator] || ', '
  57. css_class = options[:class] || 'post-categories'
  58. content_tag(:div, class: css_class) do
  59. categories.map { |cat| category_link(cat, options) }.join(separator).html_safe
  60. end
  61. end
  62. # Render tag list for post
  63. 1 def render_post_tags(post, options = {})
  64. tags = post_tags(post)
  65. then: 0 else: 0 return '' if tags.empty?
  66. separator = options[:separator] || ' '
  67. css_class = options[:class] || 'post-tags'
  68. content_tag(:div, class: css_class) do
  69. tags.map { |tag| tag_link(tag, class: 'tag-badge') }.join(separator).html_safe
  70. end
  71. end
  72. # Get term by slug and taxonomy
  73. 1 def find_term(taxonomy_slug, term_slug)
  74. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  75. else: 0 then: 0 return nil unless taxonomy
  76. taxonomy.terms.friendly.find(term_slug)
  77. rescue ActiveRecord::RecordNotFound
  78. nil
  79. end
  80. # Get posts by category
  81. 1 def posts_in_category(category_slug, limit = nil)
  82. term = find_term('category', category_slug)
  83. else: 0 then: 0 return Post.none unless term
  84. posts = Post.published_status.visible_to_public
  85. .joins(:term_relationships)
  86. .where(term_relationships: { term_id: term.id })
  87. .order(published_at: :desc)
  88. .distinct
  89. then: 0 else: 0 limit ? posts.limit(limit) : posts
  90. end
  91. # Get posts by tag
  92. 1 def posts_with_tag(tag_slug, limit = nil)
  93. term = find_term('tag', tag_slug)
  94. else: 0 then: 0 return Post.none unless term
  95. posts = Post.published_status.visible_to_public
  96. .joins(:term_relationships)
  97. .where(term_relationships: { term_id: term.id })
  98. .order(published_at: :desc)
  99. .distinct
  100. then: 0 else: 0 limit ? posts.limit(limit) : posts
  101. end
  102. # Get popular categories (most posts)
  103. 1 def popular_categories(limit = 10)
  104. else: 0 then: 0 return [] unless category_taxonomy
  105. category_taxonomy.terms
  106. .joins(:term_relationships)
  107. .where(term_relationships: { object_type: 'Post' })
  108. .group('terms.id')
  109. .order('COUNT(term_relationships.id) DESC')
  110. .limit(limit)
  111. end
  112. # Get popular tags (most posts)
  113. 1 def popular_tags(limit = 10)
  114. else: 0 then: 0 return [] unless tag_taxonomy
  115. tag_taxonomy.terms
  116. .joins(:term_relationships)
  117. .where(term_relationships: { object_type: 'Post' })
  118. .group('terms.id')
  119. .order('COUNT(term_relationships.id) DESC')
  120. .limit(limit)
  121. end
  122. # Render taxonomy cloud (tags or categories)
  123. 1 def render_taxonomy_cloud(taxonomy_slug, options = {})
  124. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  125. else: 0 then: 0 return '' unless taxonomy
  126. terms = taxonomy.terms
  127. .joins(:term_relationships)
  128. .where(term_relationships: { object_type: 'Post' })
  129. .group('terms.id')
  130. .select('terms.*, COUNT(term_relationships.id) as post_count')
  131. .order(:name)
  132. then: 0 else: 0 return '' if terms.empty?
  133. max_count = terms.maximum('post_count') || 1
  134. min_count = terms.minimum('post_count') || 1
  135. content_tag(:div, class: options[:class] || 'taxonomy-cloud') do
  136. terms.map do |term|
  137. size = calculate_cloud_size(term.post_count, min_count, max_count)
  138. link_to term.name,
  139. then: 0 else: 0 taxonomy_slug == 'tag' ? "/blog/tag/#{term.slug}" : "/blog/category/#{term.slug}",
  140. class: "cloud-item cloud-size-#{size}",
  141. title: "#{term.post_count} posts"
  142. end.join(' ').html_safe
  143. end
  144. end
  145. 1 private
  146. 1 def calculate_cloud_size(count, min, max)
  147. then: 0 else: 0 return 3 if min == max
  148. # Scale from 1 to 5
  149. ((count - min).to_f / (max - min) * 4).round + 1
  150. end
  151. end

app/helpers/toggle_switch_helper.rb

27.59% lines covered

0.0% branches covered

58 relevant lines. 16 lines covered and 42 lines missed.
26 total branches, 0 branches covered and 26 branches missed.
    
  1. 1 module ToggleSwitchHelper
  2. # Helper method to create a toggle switch with label
  3. #
  4. # Usage:
  5. # <%= toggle_switch(form, :active, 'Enable Feature', description: 'Turn this feature on or off') %>
  6. # <%= toggle_switch_tag('setting', '1', false, 'Enable Setting', class: 'toggle-success') %>
  7. #
  8. 1 def toggle_switch(form, field_name, label_text, **options)
  9. description = options.delete(:description)
  10. size = options.delete(:size) || 'default'
  11. color = options.delete(:color) || 'default'
  12. then: 0 else: 0 wrapper_class = "toggle-with-#{description ? 'description' : 'label'}"
  13. then: 0 else: 0 wrapper_class += " toggle-#{size}" if size != 'default'
  14. then: 0 else: 0 wrapper_class += " toggle-#{color}" if color != 'default'
  15. then: 0 else: 0 wrapper_class += " #{options.delete(:wrapper_class)}" if options[:wrapper_class]
  16. content_tag(:div, class: wrapper_class) do
  17. concat form.check_box(field_name, options)
  18. content_tag(:div, class: 'toggle-content') do
  19. concat content_tag(:label, label_text, for: "#{form.object_name}_#{field_name}")
  20. then: 0 else: 0 concat content_tag(:p, description) if description.present?
  21. end
  22. end
  23. end
  24. 1 def toggle_switch_tag(name, value, checked, label_text, **options)
  25. description = options.delete(:description)
  26. size = options.delete(:size) || 'default'
  27. color = options.delete(:color) || 'default'
  28. then: 0 else: 0 wrapper_class = "toggle-with-#{description ? 'description' : 'label'}"
  29. then: 0 else: 0 wrapper_class += " toggle-#{size}" if size != 'default'
  30. then: 0 else: 0 wrapper_class += " toggle-#{color}" if color != 'default'
  31. then: 0 else: 0 wrapper_class += " #{options.delete(:wrapper_class)}" if options[:wrapper_class]
  32. checkbox_id = options.delete(:id) || "#{name}_#{value}".gsub(/[\[\]]/, '_').gsub(/_+/, '_').chomp('_')
  33. content_tag(:div, class: wrapper_class) do
  34. concat check_box_tag(name, value, checked, options.merge(id: checkbox_id))
  35. content_tag(:div, class: 'toggle-content') do
  36. concat content_tag(:label, label_text, for: checkbox_id)
  37. then: 0 else: 0 concat content_tag(:p, description) if description.present?
  38. end
  39. end
  40. end
  41. # Helper to create a toggle switch group
  42. 1 def toggle_switch_group(**options)
  43. direction = options.delete(:direction) || 'vertical'
  44. then: 0 else: 0 group_class = direction == 'horizontal' ? 'toggle-group-horizontal' : 'toggle-group'
  45. then: 0 else: 0 group_class += " #{options[:class]}" if options[:class]
  46. content_tag(:div, class: group_class, **options.except(:class)) do
  47. then: 0 else: 0 yield if block_given?
  48. end
  49. end
  50. # Helper for simple toggle switches without labels
  51. 1 def simple_toggle_switch(form, field_name, **options)
  52. form.check_box(field_name, options)
  53. end
  54. 1 def simple_toggle_switch_tag(name, value, checked, **options)
  55. check_box_tag(name, value, checked, options)
  56. end
  57. # Helper to create toggle switches with different colors
  58. 1 def success_toggle_switch(form, field_name, label_text, **options)
  59. toggle_switch(form, field_name, label_text, color: 'success', **options)
  60. end
  61. 1 def warning_toggle_switch(form, field_name, label_text, **options)
  62. toggle_switch(form, field_name, label_text, color: 'warning', **options)
  63. end
  64. 1 def danger_toggle_switch(form, field_name, label_text, **options)
  65. toggle_switch(form, field_name, label_text, color: 'danger', **options)
  66. end
  67. # Helper for different sizes
  68. 1 def small_toggle_switch(form, field_name, label_text, **options)
  69. toggle_switch(form, field_name, label_text, size: 'sm', **options)
  70. end
  71. 1 def large_toggle_switch(form, field_name, label_text, **options)
  72. toggle_switch(form, field_name, label_text, size: 'lg', **options)
  73. end
  74. 1 def small_toggle_switch_tag(name, value, checked, label_text, **options)
  75. toggle_switch_tag(name, value, checked, label_text, size: 'sm', **options)
  76. end
  77. 1 def large_toggle_switch_tag(name, value, checked, label_text, **options)
  78. toggle_switch_tag(name, value, checked, label_text, size: 'lg', **options)
  79. end
  80. # Helper for loading state
  81. 1 def loading_toggle_switch(form, field_name, label_text, **options)
  82. toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-loading', **options)
  83. end
  84. # Helper for error state
  85. 1 def error_toggle_switch(form, field_name, label_text, **options)
  86. toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-error', **options)
  87. end
  88. # Helper for success state
  89. 1 def success_state_toggle_switch(form, field_name, label_text, **options)
  90. toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-success', **options)
  91. end
  92. end

app/jobs/advanced_analytics_processing_job.rb

0.0% lines covered

100.0% branches covered

312 relevant lines. 0 lines covered and 312 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AdvancedAnalyticsProcessingJob < ApplicationJob
  3. queue_as :analytics
  4. def perform(content_id, content_type, engagement_data)
  5. return unless content_id.present? && content_type.present?
  6. begin
  7. # Process advanced analytics
  8. process_user_behavior_analysis(content_id, content_type, engagement_data)
  9. process_content_performance_analysis(content_id, content_type, engagement_data)
  10. process_engagement_patterns(content_id, content_type, engagement_data)
  11. process_predictive_insights(content_id, content_type, engagement_data)
  12. rescue => e
  13. Rails.logger.error "Advanced analytics processing failed: #{e.message}"
  14. raise e
  15. end
  16. end
  17. private
  18. def process_user_behavior_analysis(content_id, content_type, engagement_data)
  19. # Analyze user behavior patterns
  20. user_id = engagement_data[:user_id]
  21. return unless user_id.present?
  22. # Update user behavior profile
  23. behavior_data = {
  24. content_id: content_id,
  25. content_type: content_type,
  26. engagement_level: calculate_engagement_level(engagement_data),
  27. reading_pattern: analyze_reading_pattern(engagement_data),
  28. interaction_pattern: analyze_interaction_pattern(engagement_data),
  29. timestamp: Time.current
  30. }
  31. # Store behavior analysis
  32. Rails.cache.write("user_behavior:#{user_id}:latest", behavior_data, expires_in: 1.day)
  33. # Update user cohort if applicable
  34. update_user_cohort(user_id, behavior_data)
  35. end
  36. def process_content_performance_analysis(content_id, content_type, engagement_data)
  37. # Analyze content performance
  38. performance_data = {
  39. engagement_score: calculate_content_engagement_score(engagement_data),
  40. readability_score: calculate_readability_score(engagement_data),
  41. retention_rate: calculate_retention_rate(content_id, content_type),
  42. bounce_rate: calculate_bounce_rate(content_id, content_type),
  43. conversion_potential: calculate_conversion_potential(engagement_data)
  44. }
  45. # Store performance analysis
  46. cache_key = "content_performance:#{content_type.downcase}:#{content_id}"
  47. Rails.cache.write(cache_key, performance_data, expires_in: 1.hour)
  48. end
  49. def process_engagement_patterns(content_id, content_type, engagement_data)
  50. # Process engagement patterns for insights
  51. patterns = {
  52. time_patterns: analyze_time_patterns(engagement_data),
  53. device_patterns: analyze_device_patterns(engagement_data),
  54. geographic_patterns: analyze_geographic_patterns(engagement_data),
  55. content_patterns: analyze_content_patterns(content_id, content_type)
  56. }
  57. # Store engagement patterns
  58. Rails.cache.write("engagement_patterns:#{content_id}", patterns, expires_in: 6.hours)
  59. end
  60. def process_predictive_insights(content_id, content_type, engagement_data)
  61. # Generate predictive insights
  62. insights = {
  63. content_recommendation_score: calculate_recommendation_score(content_id, content_type),
  64. viral_potential: calculate_viral_potential(engagement_data),
  65. engagement_prediction: predict_future_engagement(content_id, content_type),
  66. user_retention_prediction: predict_user_retention(engagement_data)
  67. }
  68. # Store predictive insights
  69. Rails.cache.write("predictive_insights:#{content_id}", insights, expires_in: 1.day)
  70. end
  71. def calculate_engagement_level(engagement_data)
  72. score = 0
  73. # Reading time contribution (0-40 points)
  74. reading_time = engagement_data[:reading_time]&.to_i || 0
  75. score += [reading_time / 10, 40].min
  76. # Scroll depth contribution (0-30 points)
  77. scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
  78. score += (scroll_depth * 0.3).round
  79. # Interaction contribution (0-30 points)
  80. interactions = engagement_data[:interactions]&.length || 0
  81. score += [interactions * 5, 30].min
  82. case score
  83. when 0..30
  84. 'low'
  85. when 31..60
  86. 'medium'
  87. else
  88. 'high'
  89. end
  90. end
  91. def analyze_reading_pattern(engagement_data)
  92. reading_time = engagement_data[:reading_time]&.to_i || 0
  93. scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
  94. if reading_time > 60 && scroll_depth > 80
  95. 'deep_reader'
  96. elsif reading_time > 30 && scroll_depth > 50
  97. 'engaged_reader'
  98. elsif reading_time > 10
  99. 'casual_reader'
  100. else
  101. 'browser'
  102. end
  103. end
  104. def analyze_interaction_pattern(engagement_data)
  105. interactions = engagement_data[:interactions] || []
  106. if interactions.length > 5
  107. 'highly_interactive'
  108. elsif interactions.length > 2
  109. 'moderately_interactive'
  110. elsif interactions.length > 0
  111. 'slightly_interactive'
  112. else
  113. 'passive'
  114. end
  115. end
  116. def update_user_cohort(user_id, behavior_data)
  117. # Update user cohort based on behavior
  118. cohort_type = determine_user_cohort(behavior_data)
  119. if cohort_type
  120. AdvancedAnalyticsService.track_user_cohort(user_id, cohort_type, behavior_data)
  121. end
  122. end
  123. def determine_user_cohort(behavior_data)
  124. engagement_level = behavior_data[:engagement_level]
  125. reading_pattern = behavior_data[:reading_pattern]
  126. case [engagement_level, reading_pattern]
  127. when ['high', 'deep_reader']
  128. 'power_readers'
  129. when ['medium', 'engaged_reader']
  130. 'engaged_readers'
  131. when ['low', 'casual_reader']
  132. 'casual_readers'
  133. else
  134. 'browsers'
  135. end
  136. end
  137. def calculate_content_engagement_score(engagement_data)
  138. # Calculate overall engagement score (0-100)
  139. score = 0
  140. # Reading time component (40%)
  141. reading_time = engagement_data[:reading_time]&.to_i || 0
  142. score += (reading_time / 2) * 0.4
  143. # Scroll depth component (30%)
  144. scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
  145. score += scroll_depth * 0.3
  146. # Interaction component (30%)
  147. interactions = engagement_data[:interactions]&.length || 0
  148. score += [interactions * 10, 30].min * 0.3
  149. [score, 100].min.round(2)
  150. end
  151. def calculate_readability_score(engagement_data)
  152. # Calculate readability based on engagement metrics
  153. reading_time = engagement_data[:reading_time]&.to_i || 0
  154. scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
  155. # Simple readability score based on engagement
  156. if reading_time > 60 && scroll_depth > 80
  157. 90 # Very readable
  158. elsif reading_time > 30 && scroll_depth > 50
  159. 70 # Readable
  160. elsif reading_time > 10 && scroll_depth > 20
  161. 50 # Somewhat readable
  162. else
  163. 30 # Difficult to read
  164. end
  165. end
  166. def calculate_retention_rate(content_id, content_type)
  167. # Calculate retention rate for content
  168. total_views = Pageview.where(path: get_content_path(content_id, content_type)).count
  169. return 0 if total_views.zero?
  170. retained_views = Pageview.where(
  171. path: get_content_path(content_id, content_type),
  172. time_on_page: 30..Float::INFINITY
  173. ).count
  174. (retained_views.to_f / total_views * 100).round(2)
  175. end
  176. def calculate_bounce_rate(content_id, content_type)
  177. # Calculate bounce rate for content
  178. content_path = get_content_path(content_id, content_type)
  179. total_sessions = Pageview.where(path: content_path).distinct.count(:session_id)
  180. return 0 if total_sessions.zero?
  181. single_page_sessions = Pageview.where(path: content_path)
  182. .group(:session_id)
  183. .having('COUNT(*) = 1')
  184. .count
  185. (single_page_sessions.to_f / total_sessions * 100).round(2)
  186. end
  187. def calculate_conversion_potential(engagement_data)
  188. # Calculate conversion potential based on engagement
  189. score = calculate_content_engagement_score(engagement_data)
  190. case score
  191. when 80..100
  192. 'high'
  193. when 60..79
  194. 'medium'
  195. when 40..59
  196. 'low'
  197. else
  198. 'very_low'
  199. end
  200. end
  201. def analyze_time_patterns(engagement_data)
  202. # Analyze time-based patterns
  203. timestamp = engagement_data[:timestamp] || Time.current
  204. {
  205. hour: timestamp.hour,
  206. day_of_week: timestamp.wday,
  207. is_weekend: timestamp.wday.in?([0, 6]),
  208. is_business_hours: timestamp.hour.between?(9, 17)
  209. }
  210. end
  211. def analyze_device_patterns(engagement_data)
  212. # Analyze device patterns
  213. user_agent = engagement_data[:user_agent] || ''
  214. {
  215. device_type: detect_device_type(user_agent),
  216. browser: detect_browser(user_agent),
  217. os: detect_operating_system(user_agent)
  218. }
  219. end
  220. def analyze_geographic_patterns(engagement_data)
  221. # Analyze geographic patterns
  222. {
  223. country: engagement_data[:country_code],
  224. region: engagement_data[:region],
  225. city: engagement_data[:city]
  226. }
  227. end
  228. def analyze_content_patterns(content_id, content_type)
  229. # Analyze content-specific patterns
  230. content = content_type.constantize.find_by(id: content_id)
  231. return {} unless content
  232. {
  233. content_length: content.content&.length || 0,
  234. has_images: content.content&.include?('<img') || false,
  235. has_videos: content.content&.include?('<video') || false,
  236. category: content.respond_to?(:category) ? content.category&.name : nil,
  237. tags: content.respond_to?(:tags) ? content.tags.pluck(:name) : []
  238. }
  239. end
  240. def calculate_recommendation_score(content_id, content_type)
  241. # Calculate content recommendation score
  242. performance_data = Rails.cache.read("content_performance:#{content_type.downcase}:#{content_id}")
  243. return 0 unless performance_data
  244. engagement_score = performance_data[:engagement_score] || 0
  245. retention_rate = performance_data[:retention_rate] || 0
  246. (engagement_score + retention_rate) / 2
  247. end
  248. def calculate_viral_potential(engagement_data)
  249. # Calculate viral potential based on engagement
  250. score = calculate_content_engagement_score(engagement_data)
  251. case score
  252. when 90..100
  253. 'high'
  254. when 70..89
  255. 'medium'
  256. when 50..69
  257. 'low'
  258. else
  259. 'very_low'
  260. end
  261. end
  262. def predict_future_engagement(content_id, content_type)
  263. # Predict future engagement based on current patterns
  264. current_performance = Rails.cache.read("content_performance:#{content_type.downcase}:#{content_id}")
  265. return 'unknown' unless current_performance
  266. engagement_score = current_performance[:engagement_score] || 0
  267. case engagement_score
  268. when 80..100
  269. 'increasing'
  270. when 60..79
  271. 'stable'
  272. when 40..59
  273. 'decreasing'
  274. else
  275. 'declining'
  276. end
  277. end
  278. def predict_user_retention(engagement_data)
  279. # Predict user retention based on engagement
  280. engagement_level = calculate_engagement_level(engagement_data)
  281. reading_pattern = analyze_reading_pattern(engagement_data)
  282. case [engagement_level, reading_pattern]
  283. when ['high', 'deep_reader']
  284. 'high'
  285. when ['medium', 'engaged_reader']
  286. 'medium'
  287. when ['low', 'casual_reader']
  288. 'low'
  289. else
  290. 'very_low'
  291. end
  292. end
  293. def get_content_path(content_id, content_type)
  294. # Get the path for content
  295. case content_type
  296. when 'Post'
  297. post = Post.find_by(id: content_id)
  298. post ? "/posts/#{post.slug}" : nil
  299. when 'Page'
  300. page = Page.find_by(id: content_id)
  301. page ? "/pages/#{page.slug}" : nil
  302. else
  303. nil
  304. end
  305. end
  306. def detect_device_type(user_agent)
  307. user_agent = user_agent.downcase
  308. if user_agent.include?('mobile') || user_agent.include?('android') || user_agent.include?('iphone')
  309. 'mobile'
  310. elsif user_agent.include?('tablet') || user_agent.include?('ipad')
  311. 'tablet'
  312. else
  313. 'desktop'
  314. end
  315. end
  316. def detect_browser(user_agent)
  317. user_agent = user_agent.downcase
  318. if user_agent.include?('chrome')
  319. 'Chrome'
  320. elsif user_agent.include?('firefox')
  321. 'Firefox'
  322. elsif user_agent.include?('safari')
  323. 'Safari'
  324. elsif user_agent.include?('edge')
  325. 'Edge'
  326. else
  327. 'Other'
  328. end
  329. end
  330. def detect_operating_system(user_agent)
  331. user_agent = user_agent.downcase
  332. if user_agent.include?('windows')
  333. 'Windows'
  334. elsif user_agent.include?('mac')
  335. 'macOS'
  336. elsif user_agent.include?('linux')
  337. 'Linux'
  338. elsif user_agent.include?('android')
  339. 'Android'
  340. elsif user_agent.include?('ios')
  341. 'iOS'
  342. else
  343. 'Other'
  344. end
  345. end
  346. end

app/jobs/analytics_archive_job.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsArchiveJob < ApplicationJob
  2. queue_as :default
  3. def perform
  4. Rails.logger.info "Starting analytics archive job"
  5. begin
  6. archived_count = AnalyticsArchiveService.instance.archive_old_data
  7. Rails.logger.info "Analytics archive job completed successfully. Archived #{archived_count} records."
  8. # Schedule next archive job
  9. AnalyticsArchiveService.instance.schedule_auto_archive
  10. rescue => e
  11. Rails.logger.error "Analytics archive job failed: #{e.message}"
  12. Rails.logger.error e.backtrace.join("\n")
  13. # Retry the job with exponential backoff
  14. raise e
  15. end
  16. end
  17. end

app/jobs/analytics_processing_job.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsProcessingJob < ApplicationJob
  2. queue_as :analytics
  3. def perform(pageview_data)
  4. # Process pageview data in background for better performance
  5. begin
  6. # Batch process multiple pageviews if data is an array
  7. if pageview_data.is_a?(Array)
  8. process_batch(pageview_data)
  9. else
  10. process_single(pageview_data)
  11. end
  12. rescue => e
  13. Rails.logger.error "Analytics processing failed: #{e.message}"
  14. # Don't retry to avoid infinite loops
  15. end
  16. end
  17. private
  18. def process_single(data)
  19. # Create pageview with error handling
  20. Pageview.create!(data)
  21. rescue ActiveRecord::RecordInvalid => e
  22. Rails.logger.error "Invalid pageview data: #{e.message}"
  23. end
  24. def process_batch(pageview_data_array)
  25. # Batch insert for better performance
  26. Pageview.insert_all(pageview_data_array, on_duplicate: :ignore)
  27. rescue => e
  28. Rails.logger.error "Batch analytics processing failed: #{e.message}"
  29. end
  30. end

app/jobs/analytics_retention_job.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsRetentionJob < ApplicationJob
  2. queue_as :low_priority
  3. def perform
  4. # Run analytics data retention cleanup
  5. AnalyticsRetentionService.cleanup_old_data
  6. # Schedule next cleanup (weekly)
  7. AnalyticsRetentionJob.set(wait: 1.week).perform_later
  8. rescue => e
  9. Rails.logger.error "Analytics retention job failed: #{e.message}"
  10. end
  11. end

app/jobs/application_job.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApplicationJob < ActiveJob::Base
  2. # Automatically retry jobs that encountered a deadlock
  3. # retry_on ActiveRecord::Deadlocked
  4. # Most jobs are safe to ignore if the underlying records are no longer available
  5. # discard_on ActiveJob::DeserializationError
  6. end

app/jobs/check_updates_job.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CheckUpdatesJob < ApplicationJob
  2. queue_as :default
  3. def perform
  4. # Check for updates from GitHub
  5. update_info = Railspress::UpdateChecker.check_for_updates
  6. # If update is available, notify administrators
  7. if update_info[:update_available]
  8. notify_administrators(update_info)
  9. end
  10. # Log the check
  11. Rails.logger.info "Update check completed: Current #{update_info[:current_version]}, Latest #{update_info[:latest_version]}"
  12. end
  13. private
  14. def notify_administrators
  15. # Find all administrator users
  16. User.administrator.find_each do |admin|
  17. # Send notification (could be email, in-app notification, etc.)
  18. Rails.logger.info "Notifying admin #{admin.email} of available update"
  19. # TODO: Implement actual notification system
  20. # UpdateNotificationMailer.update_available(admin, update_info).deliver_later
  21. end
  22. end
  23. end

app/jobs/content_analytics_update_job.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class ContentAnalyticsUpdateJob < ApplicationJob
  3. queue_as :analytics
  4. def perform(content_id, content_type, engagement_data)
  5. return unless content_id.present? && content_type.present?
  6. begin
  7. # Update content analytics in the database
  8. update_content_metrics(content_id, content_type, engagement_data)
  9. # Update real-time analytics cache
  10. update_realtime_cache(content_id, content_type, engagement_data)
  11. # Trigger advanced analytics processing
  12. trigger_advanced_processing(content_id, content_type, engagement_data)
  13. rescue => e
  14. Rails.logger.error "Content analytics update failed: #{e.message}"
  15. raise e
  16. end
  17. end
  18. private
  19. def update_content_metrics(content_id, content_type, engagement_data)
  20. # Update aggregated metrics for content
  21. case content_type
  22. when 'Post'
  23. update_post_metrics(content_id, engagement_data)
  24. when 'Page'
  25. update_page_metrics(content_id, engagement_data)
  26. end
  27. end
  28. def update_post_metrics(post_id, engagement_data)
  29. post = Post.find_by(id: post_id)
  30. return unless post
  31. # Update post engagement metrics
  32. post_analytics = ContentAnalyticsService.post_analytics(post_id, period: :month)
  33. # Cache the updated analytics
  34. Rails.cache.write("post_analytics:#{post_id}:month", post_analytics, expires_in: 1.hour)
  35. end
  36. def update_page_metrics(page_id, engagement_data)
  37. page = Page.find_by(id: page_id)
  38. return unless page
  39. # Update page engagement metrics
  40. page_analytics = ContentAnalyticsService.page_analytics(page_id, period: :month)
  41. # Cache the updated analytics
  42. Rails.cache.write("page_analytics:#{page_id}:month", page_analytics, expires_in: 1.hour)
  43. end
  44. def update_realtime_cache(content_id, content_type, engagement_data)
  45. # Update real-time engagement cache
  46. cache_key = "realtime_engagement:#{content_type.downcase}:#{content_id}"
  47. current_data = Rails.cache.read(cache_key) || {}
  48. updated_data = current_data.merge(engagement_data)
  49. Rails.cache.write(cache_key, updated_data, expires_in: 5.minutes)
  50. end
  51. def trigger_advanced_processing(content_id, content_type, engagement_data)
  52. # Trigger advanced analytics processing if engagement is significant
  53. if significant_engagement?(engagement_data)
  54. AdvancedAnalyticsProcessingJob.perform_later(content_id, content_type, engagement_data)
  55. end
  56. end
  57. def significant_engagement?(engagement_data)
  58. # Consider engagement significant if:
  59. # - Reading time > 30 seconds
  60. # - Scroll depth > 50%
  61. # - Multiple interactions
  62. reading_time = engagement_data[:reading_time]&.to_i || 0
  63. scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
  64. interactions = engagement_data[:interactions]&.length || 0
  65. reading_time > 30 || scroll_depth > 50 || interactions > 3
  66. end
  67. end

app/jobs/deliver_webhook_job.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class DeliverWebhookJob < ApplicationJob
  2. queue_as :default
  3. # Retry with exponential backoff
  4. retry_on StandardError, wait: :exponentially_longer, attempts: 5
  5. def perform(webhook_delivery_id)
  6. delivery = WebhookDelivery.find(webhook_delivery_id)
  7. webhook = delivery.webhook
  8. # Skip if webhook is inactive
  9. return unless webhook.active?
  10. # Prepare request
  11. uri = URI(webhook.url)
  12. http = Net::HTTP.new(uri.host, uri.port)
  13. http.use_ssl = (uri.scheme == 'https')
  14. http.open_timeout = webhook.timeout
  15. http.read_timeout = webhook.timeout
  16. # Create request
  17. request = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.path)
  18. # Set headers
  19. delivery.signed_headers.each do |key, value|
  20. request[key] = value
  21. end
  22. # Set body
  23. request.body = delivery.payload.to_json
  24. # Send request
  25. begin
  26. response = http.request(request)
  27. if response.code.to_i >= 200 && response.code.to_i < 300
  28. # Success
  29. delivery.mark_success!(response.code.to_i, response.body)
  30. Rails.logger.info "Webhook delivered successfully: #{webhook.name} (#{delivery.event_type})"
  31. else
  32. # HTTP error
  33. delivery.mark_failed!(
  34. "HTTP #{response.code}: #{response.message}",
  35. response.code.to_i,
  36. response.body
  37. )
  38. Rails.logger.warn "Webhook delivery failed: #{webhook.name} (HTTP #{response.code})"
  39. end
  40. rescue Timeout::Error => e
  41. delivery.mark_failed!("Request timeout after #{webhook.timeout}s")
  42. Rails.logger.error "Webhook timeout: #{webhook.name} - #{e.message}"
  43. rescue SocketError, Errno::ECONNREFUSED => e
  44. delivery.mark_failed!("Connection failed: #{e.message}")
  45. Rails.logger.error "Webhook connection failed: #{webhook.name} - #{e.message}"
  46. rescue => e
  47. delivery.mark_failed!("Unexpected error: #{e.message}")
  48. Rails.logger.error "Webhook error: #{webhook.name} - #{e.class}: #{e.message}"
  49. end
  50. end
  51. end

app/jobs/maxmind_update_job.rb

0.0% lines covered

100.0% branches covered

37 relevant lines. 0 lines covered and 37 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class MaxmindUpdateJob < ApplicationJob
  3. queue_as :default
  4. def perform(update_type = :full)
  5. begin
  6. Rails.logger.info "Starting MaxMind database update: #{update_type}"
  7. case update_type
  8. when :full
  9. MaxmindUpdaterService.update_databases
  10. when :city
  11. MaxmindUpdaterService.download_database('GeoLite2-City')
  12. when :country
  13. MaxmindUpdaterService.download_database('GeoLite2-Country')
  14. end
  15. Rails.logger.info "MaxMind database update completed successfully"
  16. # Update the last update timestamp
  17. SiteSetting.set('maxmind_last_update', Time.current.iso8601)
  18. # Send notification if configured
  19. send_update_notification if SiteSetting.get('maxmind_notifications_enabled', false)
  20. rescue => e
  21. Rails.logger.error "MaxMind database update failed: #{e.message}"
  22. Rails.logger.error e.backtrace.join("\n")
  23. # Send error notification
  24. send_error_notification(e) if SiteSetting.get('maxmind_notifications_enabled', false)
  25. raise e
  26. end
  27. end
  28. private
  29. def send_update_notification
  30. # Send notification to admin users about successful update
  31. admin_users = User.where(administrator: true)
  32. admin_users.each do |user|
  33. # You could implement email notifications here
  34. Rails.logger.info "MaxMind update notification sent to #{user.email}"
  35. end
  36. end
  37. def send_error_notification(error)
  38. # Send error notification to admin users
  39. admin_users = User.where(administrator: true)
  40. admin_users.each do |user|
  41. # You could implement error email notifications here
  42. Rails.logger.error "MaxMind update error notification sent to #{user.email}: #{error.message}"
  43. end
  44. end
  45. end

app/jobs/optimize_image_job.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Background job for image optimization
  2. class OptimizeImageJob < ApplicationJob
  3. queue_as :default
  4. def perform(medium_id:, optimization_type: 'upload', request_context: {})
  5. medium = Medium.find_by(id: medium_id)
  6. return unless medium&.upload&.file&.attached?
  7. return unless medium.image?
  8. Rails.logger.info "Starting image optimization for medium #{medium_id}"
  9. # Use the ImageOptimizationService with logging
  10. optimization_service = ImageOptimizationService.new(
  11. medium,
  12. optimization_type: optimization_type,
  13. request_context: request_context
  14. )
  15. # Optimize the main image
  16. if optimization_service.optimize!
  17. Rails.logger.info "Main image optimization completed for medium #{medium_id}"
  18. else
  19. Rails.logger.info "Main image optimization skipped for medium #{medium_id}"
  20. end
  21. Rails.logger.info "Image optimization process completed for medium #{medium_id}"
  22. rescue => e
  23. Rails.logger.error "Image optimization failed for medium #{medium_id}: #{e.message}"
  24. Rails.logger.error e.backtrace.join("\n")
  25. end
  26. end

app/jobs/plugin_task_worker_job.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PluginTaskWorkerJob < ApplicationJob
  2. queue_as :default
  3. def perform(plugin_identifier, task_name)
  4. plugin = Railspress::PluginSystem.get_plugin(plugin_identifier)
  5. unless plugin
  6. Rails.logger.error "Plugin not found: #{plugin_identifier}"
  7. return
  8. end
  9. # Find the task
  10. task = Railspress::PluginSystem.instance_variable_get(:@scheduled_tasks)[plugin_identifier]
  11. &.find { |t| t[:name] == task_name }
  12. unless task
  13. Rails.logger.error "Task not found: #{plugin_identifier}:#{task_name}"
  14. return
  15. end
  16. Rails.logger.info "Executing scheduled task: #{plugin_identifier}:#{task_name}"
  17. begin
  18. # Execute the task
  19. task[:block].call
  20. Rails.logger.info "Task completed successfully: #{plugin_identifier}:#{task_name}"
  21. rescue => e
  22. Rails.logger.error "Task failed: #{plugin_identifier}:#{task_name} - #{e.message}"
  23. Rails.logger.error e.backtrace.first(5).join("\n")
  24. raise e
  25. end
  26. end
  27. end

app/jobs/slick_forms_integration_job.rb

0.0% lines covered

100.0% branches covered

186 relevant lines. 0 lines covered and 186 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # FluentFormsIntegrationJob
  2. # Handles third-party integrations (Slack, Mailchimp, Webhooks, etc.)
  3. class FluentFormsIntegrationJob < ApplicationJob
  4. queue_as :default
  5. def perform(submission_id)
  6. @submission = fetch_submission(submission_id)
  7. return unless @submission
  8. @form = fetch_form(@submission[:form_id])
  9. return unless @form
  10. @plugin = FluentFormsPro.new
  11. # Process all enabled integrations
  12. process_slack_integration if slack_enabled?
  13. process_mailchimp_integration if mailchimp_enabled?
  14. process_webhook_integration if webhook_enabled?
  15. process_zapier_integration if zapier_enabled?
  16. log_integration(submission_id, 'Integrations processed successfully')
  17. rescue => e
  18. Rails.logger.error "[Fluent Forms] Integration error: #{e.message}"
  19. log_integration(submission_id, "Integration failed: #{e.message}")
  20. end
  21. private
  22. def fetch_submission(submission_id)
  23. result = ActiveRecord::Base.connection.execute(
  24. "SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
  25. submission_id
  26. ).first
  27. return nil unless result
  28. {
  29. id: result[0],
  30. form_id: result[1],
  31. serial_number: result[2],
  32. response_data: JSON.parse(result[3] || '{}'),
  33. created_at: result[14]
  34. }
  35. end
  36. def fetch_form(form_id)
  37. result = ActiveRecord::Base.connection.execute(
  38. "SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
  39. form_id
  40. ).first
  41. return nil unless result
  42. {
  43. id: result[0],
  44. title: result[1],
  45. settings: JSON.parse(result[3] || '{}')
  46. }
  47. end
  48. # Slack Integration
  49. def slack_enabled?
  50. webhook_url = @plugin.get_setting('slack_webhook_url')
  51. integrations = @form[:settings][:integrations] || {}
  52. webhook_url.present? && integrations.dig(:slack, :enabled)
  53. end
  54. def process_slack_integration
  55. webhook_url = @plugin.get_setting('slack_webhook_url')
  56. message = format_slack_message
  57. uri = URI(webhook_url)
  58. http = Net::HTTP.new(uri.host, uri.port)
  59. http.use_ssl = true
  60. request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
  61. request.body = message.to_json
  62. response = http.request(request)
  63. if response.code.to_i == 200
  64. Rails.logger.info "[Fluent Forms] Slack notification sent for submission #{@submission[:id]}"
  65. else
  66. Rails.logger.error "[Fluent Forms] Slack notification failed: #{response.body}"
  67. end
  68. end
  69. def format_slack_message
  70. fields = @submission[:response_data].map do |key, value|
  71. {
  72. title: key.titleize,
  73. value: value,
  74. short: value.to_s.length < 40
  75. }
  76. end
  77. {
  78. text: "New form submission: #{@form[:title]}",
  79. attachments: [
  80. {
  81. color: '#36a64f',
  82. fields: fields,
  83. footer: 'Fluent Forms Pro',
  84. ts: Time.current.to_i
  85. }
  86. ]
  87. }
  88. end
  89. # Mailchimp Integration
  90. def mailchimp_enabled?
  91. api_key = @plugin.get_setting('mailchimp_api_key')
  92. integrations = @form[:settings][:integrations] || {}
  93. api_key.present? && integrations.dig(:mailchimp, :enabled)
  94. end
  95. def process_mailchimp_integration
  96. api_key = @plugin.get_setting('mailchimp_api_key')
  97. list_id = @form[:settings].dig(:integrations, :mailchimp, :list_id)
  98. return unless list_id.present?
  99. email = find_email_in_submission
  100. return unless email.present?
  101. # Extract datacenter from API key
  102. datacenter = api_key.split('-').last
  103. uri = URI("https://#{datacenter}.api.mailchimp.com/3.0/lists/#{list_id}/members")
  104. http = Net::HTTP.new(uri.host, uri.port)
  105. http.use_ssl = true
  106. request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
  107. request.basic_auth('apikey', api_key)
  108. request.body = {
  109. email_address: email,
  110. status: 'subscribed',
  111. merge_fields: extract_merge_fields
  112. }.to_json
  113. response = http.request(request)
  114. if response.code.to_i.between?(200, 299)
  115. Rails.logger.info "[Fluent Forms] Mailchimp subscriber added for submission #{@submission[:id]}"
  116. else
  117. Rails.logger.error "[Fluent Forms] Mailchimp failed: #{response.body}"
  118. end
  119. rescue => e
  120. Rails.logger.error "[Fluent Forms] Mailchimp error: #{e.message}"
  121. end
  122. def extract_merge_fields
  123. fields = {}
  124. # Map common fields
  125. if @submission[:response_data]['name'].present?
  126. name_parts = @submission[:response_data]['name'].split(' ', 2)
  127. fields['FNAME'] = name_parts[0]
  128. fields['LNAME'] = name_parts[1] if name_parts[1]
  129. end
  130. fields['FNAME'] = @submission[:response_data]['first_name'] if @submission[:response_data]['first_name']
  131. fields['LNAME'] = @submission[:response_data]['last_name'] if @submission[:response_data]['last_name']
  132. fields['PHONE'] = @submission[:response_data]['phone'] if @submission[:response_data]['phone']
  133. fields
  134. end
  135. # Webhook Integration
  136. def webhook_enabled?
  137. webhooks = @form[:settings].dig(:integrations, :webhooks) || []
  138. webhooks.any? { |w| w[:enabled] }
  139. end
  140. def process_webhook_integration
  141. webhooks = @form[:settings].dig(:integrations, :webhooks) || []
  142. webhooks.each do |webhook|
  143. next unless webhook[:enabled] && webhook[:url].present?
  144. send_webhook(webhook)
  145. end
  146. end
  147. def send_webhook(webhook)
  148. uri = URI(webhook[:url])
  149. http = Net::HTTP.new(uri.host, uri.port)
  150. http.use_ssl = uri.scheme == 'https'
  151. request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
  152. request.body = {
  153. form_id: @form[:id],
  154. form_title: @form[:title],
  155. submission_id: @submission[:id],
  156. serial_number: @submission[:serial_number],
  157. data: @submission[:response_data],
  158. created_at: @submission[:created_at]
  159. }.to_json
  160. response = http.request(request)
  161. if response.code.to_i.between?(200, 299)
  162. Rails.logger.info "[Fluent Forms] Webhook sent to #{webhook[:url]}"
  163. else
  164. Rails.logger.error "[Fluent Forms] Webhook failed: #{response.code} - #{response.body}"
  165. end
  166. rescue => e
  167. Rails.logger.error "[Fluent Forms] Webhook error: #{e.message}"
  168. end
  169. # Zapier Integration
  170. def zapier_enabled?
  171. @plugin.setting_enabled?('zapier_enabled')
  172. end
  173. def process_zapier_integration
  174. zapier_webhook = @form[:settings].dig(:integrations, :zapier, :webhook_url)
  175. return unless zapier_webhook.present?
  176. send_webhook({ url: zapier_webhook, enabled: true })
  177. end
  178. # Helper methods
  179. def find_email_in_submission
  180. @submission[:response_data].values.find { |v| v.to_s.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) }
  181. end
  182. def log_integration(submission_id, message)
  183. ActiveRecord::Base.connection.execute(
  184. "INSERT INTO ff_logs (submission_id, form_id, log_type, title, description, created_at)
  185. VALUES (?, ?, ?, ?, ?, ?)",
  186. submission_id,
  187. @submission[:form_id],
  188. 'integration',
  189. 'Third-party Integration',
  190. message,
  191. Time.current
  192. )
  193. rescue => e
  194. Rails.logger.error "[Fluent Forms] Log error: #{e.message}"
  195. end
  196. end

app/jobs/slick_forms_notification_job.rb

0.0% lines covered

100.0% branches covered

102 relevant lines. 0 lines covered and 102 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # FluentFormsNotificationJob
  2. # Sends email notifications when forms are submitted
  3. class FluentFormsNotificationJob < ApplicationJob
  4. queue_as :default
  5. def perform(submission_id)
  6. @submission = fetch_submission(submission_id)
  7. return unless @submission
  8. @form = fetch_form(@submission[:form_id])
  9. return unless @form
  10. # Send admin notification
  11. send_admin_notification if admin_notification_enabled?
  12. # Send user notification (autoresponder)
  13. send_user_notification if user_notification_enabled?
  14. # Log notification
  15. log_notification(submission_id, 'Notifications sent successfully')
  16. rescue => e
  17. Rails.logger.error "[Fluent Forms] Notification error: #{e.message}"
  18. log_notification(submission_id, "Notification failed: #{e.message}")
  19. end
  20. private
  21. def fetch_submission(submission_id)
  22. result = ActiveRecord::Base.connection.execute(
  23. "SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
  24. submission_id
  25. ).first
  26. return nil unless result
  27. {
  28. id: result[0],
  29. form_id: result[1],
  30. serial_number: result[2],
  31. response_data: JSON.parse(result[3] || '{}'),
  32. source_url: result[4],
  33. user_id: result[5],
  34. browser: result[6],
  35. device: result[7],
  36. ip_address: result[8],
  37. created_at: result[14]
  38. }
  39. end
  40. def fetch_form(form_id)
  41. result = ActiveRecord::Base.connection.execute(
  42. "SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
  43. form_id
  44. ).first
  45. return nil unless result
  46. {
  47. id: result[0],
  48. title: result[1],
  49. settings: JSON.parse(result[3] || '{}')
  50. }
  51. end
  52. def admin_notification_enabled?
  53. notifications = @form[:settings].dig(:notifications, :admin)
  54. notifications && notifications[:enabled]
  55. end
  56. def user_notification_enabled?
  57. notifications = @form[:settings].dig(:notifications, :user)
  58. notifications && notifications[:enabled]
  59. end
  60. def send_admin_notification
  61. plugin = FluentFormsPro.new
  62. admin_email = @form[:settings].dig(:notifications, :admin, :email) ||
  63. plugin.get_setting('default_from_email')
  64. subject = @form[:settings].dig(:notifications, :admin, :subject) ||
  65. "New form submission: #{@form[:title]}"
  66. return unless admin_email.present?
  67. FluentFormsMailer.admin_notification(
  68. to: admin_email,
  69. subject: subject,
  70. form: @form,
  71. submission: @submission
  72. ).deliver_now
  73. end
  74. def send_user_notification
  75. plugin = FluentFormsPro.new
  76. # Find email field in submission
  77. user_email = find_email_in_submission
  78. return unless user_email.present?
  79. subject = @form[:settings].dig(:notifications, :user, :subject) ||
  80. "Thank you for your submission"
  81. message = @form[:settings].dig(:notifications, :user, :message) ||
  82. "We have received your submission."
  83. FluentFormsMailer.user_notification(
  84. to: user_email,
  85. subject: subject,
  86. message: message,
  87. form: @form,
  88. submission: @submission
  89. ).deliver_now
  90. end
  91. def find_email_in_submission
  92. @submission[:response_data].values.find { |v| v.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) }
  93. end
  94. def log_notification(submission_id, message)
  95. ActiveRecord::Base.connection.execute(
  96. "INSERT INTO ff_logs (submission_id, form_id, log_type, title, description, created_at)
  97. VALUES (?, ?, ?, ?, ?, ?)",
  98. submission_id,
  99. @submission[:form_id],
  100. 'notification',
  101. 'Email Notification',
  102. message,
  103. Time.current
  104. )
  105. rescue => e
  106. Rails.logger.error "[Fluent Forms] Log error: #{e.message}"
  107. end
  108. end

app/jobs/webhook_job.rb

0.0% lines covered

100.0% branches covered

44 relevant lines. 0 lines covered and 44 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class WebhookJob < ApplicationJob
  2. queue_as :default
  3. retry_on StandardError, wait: :exponentially_longer, attempts: 3
  4. def perform(webhook_config, data)
  5. webhook = webhook_config.with_indifferent_access
  6. uri = URI(webhook[:url])
  7. http = Net::HTTP.new(uri.host, uri.port)
  8. http.use_ssl = uri.scheme == 'https'
  9. http.read_timeout = webhook[:timeout] || 30
  10. request_class = case webhook[:method]&.upcase
  11. when 'GET'
  12. Net::HTTP::Get
  13. when 'PUT'
  14. Net::HTTP::Put
  15. when 'PATCH'
  16. Net::HTTP::Patch
  17. when 'DELETE'
  18. Net::HTTP::Delete
  19. else
  20. Net::HTTP::Post
  21. end
  22. request = request_class.new(uri)
  23. request['Content-Type'] = 'application/json'
  24. request['User-Agent'] = 'RailsPress-Plugin-Webhook/1.0'
  25. # Add custom headers
  26. webhook[:headers]&.each do |key, value|
  27. request[key] = value
  28. end
  29. # Add webhook signature if secret is provided
  30. if webhook[:secret]
  31. payload = data.to_json
  32. signature = OpenSSL::HMAC.hexdigest('SHA256', webhook[:secret], payload)
  33. request['X-Webhook-Signature'] = "sha256=#{signature}"
  34. end
  35. # Prepare payload
  36. payload = data.to_json
  37. request.body = payload
  38. # Send request
  39. response = http.request(request)
  40. Rails.logger.info "Webhook sent to #{webhook[:url]}: #{response.code} #{response.message}"
  41. # Raise error for non-success responses to trigger retry
  42. unless response.is_a?(Net::HTTPSuccess)
  43. raise "Webhook failed with status #{response.code}: #{response.message}"
  44. end
  45. rescue => e
  46. Rails.logger.error "Webhook delivery failed: #{e.message}"
  47. raise e # Re-raise to trigger retry mechanism
  48. end
  49. end

app/mailers/application_mailer.rb

0.0% lines covered

100.0% branches covered

4 relevant lines. 0 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApplicationMailer < ActionMailer::Base
  2. default from: "from@example.com"
  3. layout "mailer"
  4. end

app/mailers/email_logging_interceptor.rb

23.08% lines covered

0.0% branches covered

13 relevant lines. 3 lines covered and 10 lines missed.
12 total branches, 0 branches covered and 12 branches missed.
    
  1. 1 class EmailLoggingInterceptor
  2. 1 def self.delivering_email(message)
  3. else: 0 then: 0 return unless ActiveRecord::Base.connection.table_exists?('email_logs')
  4. else: 0 then: 0 return unless ActiveRecord::Base.connection.table_exists?('site_settings')
  5. # Only log if logging is enabled
  6. else: 0 then: 0 return unless SiteSetting.get('email_logging_enabled', true)
  7. provider = SiteSetting.get('email_provider', 'smtp')
  8. # Log the email
  9. EmailLog.log_email(
  10. from: extract_email(message.from),
  11. to: extract_email(message.to),
  12. subject: message.subject,
  13. then: 0 else: 0 body: message.body&.raw_source || message.body.to_s,
  14. provider: provider,
  15. status: 'pending',
  16. metadata: {
  17. cc: message.cc,
  18. bcc: message.bcc,
  19. reply_to: message.reply_to,
  20. message_id: message.message_id,
  21. content_type: message.content_type
  22. }
  23. )
  24. rescue => e
  25. Rails.logger.error "Failed to log email: #{e.message}"
  26. end
  27. 1 def self.extract_email(email_field)
  28. then: 0 else: 0 return nil if email_field.nil?
  29. then: 0 if email_field.is_a?(Array)
  30. email_field.first.to_s
  31. else: 0 else
  32. email_field.to_s
  33. end
  34. end
  35. end

app/mailers/slick_forms_mailer.rb

0.0% lines covered

100.0% branches covered

37 relevant lines. 0 lines covered and 37 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # FluentFormsMailer
  2. # Handles email notifications for form submissions
  3. class FluentFormsMailer < ApplicationMailer
  4. default from: -> { default_from_email }
  5. # Admin notification email
  6. def admin_notification(to:, subject:, form:, submission:)
  7. @form = form
  8. @submission = submission
  9. @subject = subject
  10. mail(
  11. to: to,
  12. subject: subject,
  13. template_path: 'fluent_forms_mailer',
  14. template_name: 'admin_notification'
  15. )
  16. end
  17. # User notification email (autoresponder)
  18. def user_notification(to:, subject:, message:, form:, submission:)
  19. @form = form
  20. @submission = submission
  21. @message = message
  22. @subject = subject
  23. mail(
  24. to: to,
  25. subject: subject,
  26. template_path: 'fluent_forms_mailer',
  27. template_name: 'user_notification'
  28. )
  29. end
  30. private
  31. def default_from_email
  32. plugin = FluentFormsPro.new
  33. from_email = plugin.get_setting('default_from_email')
  34. from_name = plugin.get_setting('default_from_name')
  35. if from_name.present?
  36. "#{from_name} <#{from_email}>"
  37. else
  38. from_email || 'noreply@example.com'
  39. end
  40. end
  41. end

app/mailers/test_mailer.rb

0.0% lines covered

100.0% branches covered

12 relevant lines. 0 lines covered and 12 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class TestMailer < ApplicationMailer
  2. def test_email(to_address)
  3. @test_time = Time.current
  4. from_email = SiteSetting.get('default_from_email', 'noreply@railspress.com')
  5. from_name = SiteSetting.get('default_from_name', 'RailsPress')
  6. mail(
  7. from: "#{from_name} <#{from_email}>",
  8. to: to_address,
  9. subject: "Test Email from RailsPress - #{@test_time.strftime('%Y-%m-%d %H:%M:%S')}"
  10. )
  11. end
  12. end

app/middleware/allow_iframe_for_logs.rb

33.33% lines covered

0.0% branches covered

9 relevant lines. 3 lines covered and 6 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. 1 class AllowIframeForLogs
  2. 1 def initialize(app)
  3. @app = app
  4. end
  5. 1 def call(env)
  6. status, headers, body = @app.call(env)
  7. then: 0 else: 0 if env['PATH_INFO'].start_with?('/logs')
  8. headers.delete('X-Frame-Options') # Remove restrictive header
  9. headers['Content-Security-Policy'] =
  10. [headers['Content-Security-Policy'],
  11. "frame-ancestors 'self'"].compact.join('; ')
  12. end
  13. [status, headers, body]
  14. end
  15. end

app/middleware/analytics_tracker.rb

35.56% lines covered

0.0% branches covered

45 relevant lines. 16 lines covered and 29 lines missed.
14 total branches, 0 branches covered and 14 branches missed.
    
  1. 1 class AnalyticsTracker
  2. 1 def initialize(app)
  3. 1 @app = app
  4. end
  5. 1 def call(env)
  6. status, headers, response = @app.call(env)
  7. # Track pageview after response (non-blocking)
  8. then: 0 else: 0 track_pageview(env, status) if should_track?(env, status)
  9. [status, headers, response]
  10. end
  11. 1 private
  12. 1 def should_track?(env, status)
  13. request = Rack::Request.new(env)
  14. # Track successful GET and POST requests (for form submissions, etc.)
  15. else: 0 then: 0 return false unless (request.get? || request.post?) && status == 200
  16. # Skip admin, API, assets
  17. then: 0 else: 0 return false if skip_path?(request.path)
  18. # Skip if tracking disabled
  19. else: 0 then: 0 return false unless tracking_enabled?
  20. # Track everything else
  21. true
  22. end
  23. 1 def skip_path?(path)
  24. skip_patterns = [
  25. /^\/admin/,
  26. /^\/api/,
  27. /^\/assets/,
  28. /^\/packs/,
  29. /^\/uploads/,
  30. /^\/rails/,
  31. /^\/cable/,
  32. /^\/up$/,
  33. /^\/analytics\/track/, # Our own tracking endpoint
  34. /\.json$/,
  35. /\.xml$/,
  36. /\.js$/,
  37. /\.css$/,
  38. /\.png$/,
  39. /\.jpg$/,
  40. /\.gif$/,
  41. /\.ico$/
  42. ]
  43. skip_patterns.any? { |pattern| path.match?(pattern) }
  44. end
  45. 1 def tracking_enabled?
  46. # Check if analytics is enabled in settings
  47. SiteSetting.get('analytics_enabled', 'true') == 'true'
  48. rescue
  49. true # Default to enabled
  50. end
  51. 1 def consent_required?
  52. SiteSetting.get('analytics_require_consent', 'true') == 'true'
  53. rescue
  54. true
  55. end
  56. 1 def anonymize_ip?
  57. SiteSetting.get('analytics_anonymize_ip', 'true') == 'true'
  58. rescue
  59. true
  60. end
  61. 1 def track_bots?
  62. SiteSetting.get('analytics_track_bots', 'false') == 'true'
  63. rescue
  64. false
  65. end
  66. 1 def track_pageview(env, status)
  67. request = Rack::Request.new(env)
  68. # Skip if consent is required but not given
  69. then: 0 else: 0 if consent_required? && !check_consent(request)
  70. return
  71. end
  72. # Background job for tracking (non-blocking)
  73. # For now, track synchronously but could use Sidekiq
  74. Thread.new do
  75. begin
  76. Pageview.track(request, {
  77. title: extract_title(env),
  78. user_id: extract_user_id(env),
  79. session_id: extract_session_id(request),
  80. consented: check_consent(request) || !consent_required?,
  81. track_bots: track_bots?,
  82. anonymize_ip: anonymize_ip?
  83. })
  84. rescue => e
  85. Rails.logger.error "Analytics tracking error: #{e.message}"
  86. end
  87. end
  88. end
  89. 1 def extract_title(env)
  90. # Try to extract page title from response
  91. # This is a simplified version
  92. nil
  93. end
  94. 1 def extract_user_id(env)
  95. # Try to get current user from session
  96. session = env['rack.session']
  97. then: 0 else: 0 then: 0 else: 0 session['warden.user.user.key']&.first&.first
  98. rescue
  99. nil
  100. end
  101. 1 def extract_session_id(request)
  102. # Use cookie-based session or generate new one
  103. request.cookies['_railspress_session_id'] ||
  104. Digest::SHA256.hexdigest("#{request.ip}-#{request.user_agent}-#{Date.today}")[0..31]
  105. end
  106. 1 def check_consent(request)
  107. # Check if user has given analytics consent
  108. # Could be from cookie or session
  109. request.cookies['analytics_consent'] == 'true'
  110. end
  111. end

app/middleware/channel_detection_middleware.rb

28.57% lines covered

0.0% branches covered

35 relevant lines. 10 lines covered and 25 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. 1 module Railspress
  2. 1 class ChannelDetectionMiddleware
  3. 1 def initialize(app)
  4. 1 @app = app
  5. end
  6. 1 def call(env)
  7. request = ActionDispatch::Request.new(env)
  8. # Only apply channel detection to API requests
  9. then: 0 else: 0 if api_request?(request)
  10. user_agent = request.user_agent || ''
  11. device_type = detect_device_type(user_agent)
  12. channel = channel_for_device(device_type)
  13. # Add channel context to request parameters
  14. then: 0 else: 0 if channel
  15. request.params[:auto_channel] = channel.slug
  16. request.params[:device_type] = device_type.to_s
  17. request.params[:channel_context] = channel.slug
  18. end
  19. end
  20. @app.call(env)
  21. end
  22. 1 private
  23. 1 def api_request?(request)
  24. request.path.start_with?('/api/')
  25. end
  26. 1 def detect_device_type(user_agent)
  27. then: 0 else: 0 return :email if email_client?(user_agent)
  28. # Mobile detection
  29. then: 0 else: 0 if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
  30. return :mobile
  31. end
  32. # Tablet detection
  33. then: 0 else: 0 if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
  34. return :tablet
  35. end
  36. # Smart TV detection
  37. then: 0 else: 0 if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
  38. return :smart_tv
  39. end
  40. # Default to desktop
  41. :desktop
  42. end
  43. 1 def email_client?(user_agent)
  44. user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
  45. end
  46. 1 def channel_for_device(device_type)
  47. case device_type
  48. when: 0 when :mobile, :tablet
  49. Channel.find_by(slug: 'mobile')
  50. when: 0 when :smart_tv
  51. Channel.find_by(slug: 'smarttv')
  52. when: 0 when :email
  53. Channel.find_by(slug: 'newsletter')
  54. else: 0 else
  55. Channel.find_by(slug: 'web')
  56. end
  57. end
  58. end
  59. end

app/middleware/headless_mode_handler.rb

26.67% lines covered

0.0% branches covered

30 relevant lines. 8 lines covered and 22 lines missed.
22 total branches, 0 branches covered and 22 branches missed.
    
  1. 1 class HeadlessModeHandler
  2. 1 def initialize(app)
  3. 1 @app = app
  4. end
  5. 1 def call(env)
  6. request = ActionDispatch::Request.new(env)
  7. # Check if headless mode is enabled
  8. headless_enabled = SiteSetting.get('headless_mode', false)
  9. then: 0 else: 0 if headless_enabled && is_frontend_route?(request)
  10. return render_headless_error(request)
  11. end
  12. @app.call(env)
  13. end
  14. 1 private
  15. 1 def is_frontend_route?(request)
  16. path = request.path
  17. # Exclude admin, API, auth, and asset routes
  18. then: 0 else: 0 return false if path.start_with?('/admin')
  19. then: 0 else: 0 return false if path.start_with?('/api')
  20. then: 0 else: 0 return false if path.start_with?('/auth')
  21. then: 0 else: 0 return false if path.start_with?('/themes')
  22. then: 0 else: 0 return false if path.start_with?('/graphql')
  23. then: 0 else: 0 return false if path.start_with?('/assets')
  24. then: 0 else: 0 return false if path.start_with?('/rails')
  25. then: 0 else: 0 return false if path.start_with?('/__')
  26. then: 0 else: 0 return false if path == '/up' # Health check
  27. then: 0 else: 0 return false if path == '/csp-violation-report'
  28. # These are frontend routes
  29. true
  30. end
  31. 1 def render_headless_error(request)
  32. # Try to use theme's error.liquid template
  33. begin
  34. renderer = LiquidTemplateRenderer.new(SiteSetting.get('active_theme', 'nordic'))
  35. html = renderer.render('headless', {
  36. 'site' => {
  37. 'name' => SiteSetting.get('site_title', 'RailsPress')
  38. },
  39. 'request_path' => request.path
  40. }, 'error')
  41. rescue
  42. # Fallback to simple HTML
  43. html = render_simple_headless_error(request)
  44. end
  45. [
  46. 503,
  47. {
  48. 'Content-Type' => 'text/html',
  49. 'Content-Length' => html.bytesize.to_s
  50. },
  51. [html]
  52. ]
  53. end
  54. 1 def render_simple_headless_error(request)
  55. <<~HTML
  56. <!DOCTYPE html>
  57. <html lang="en">
  58. <head>
  59. <meta charset="UTF-8">
  60. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  61. <title>Headless Mode Enabled</title>
  62. <style>
  63. body {
  64. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  65. display: flex;
  66. align-items: center;
  67. justify-content: center;
  68. min-height: 100vh;
  69. margin: 0;
  70. background: #f5f5f5;
  71. color: #333;
  72. }
  73. .container {
  74. max-width: 600px;
  75. padding: 40px;
  76. background: white;
  77. border-radius: 12px;
  78. box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  79. text-align: center;
  80. }
  81. h1 {
  82. font-size: 2.5rem;
  83. margin: 0 0 16px;
  84. color: #0E7C86;
  85. }
  86. p {
  87. font-size: 1.125rem;
  88. line-height: 1.6;
  89. margin: 16px 0;
  90. color: #666;
  91. }
  92. code {
  93. background: #f5f5f5;
  94. padding: 2px 6px;
  95. border-radius: 4px;
  96. font-family: 'Monaco', 'Courier New', monospace;
  97. }
  98. .endpoints {
  99. margin: 24px 0;
  100. text-align: left;
  101. background: #f9f9f9;
  102. padding: 20px;
  103. border-radius: 8px;
  104. }
  105. .endpoints h3 {
  106. margin: 0 0 12px;
  107. font-size: 1.25rem;
  108. }
  109. .endpoints ul {
  110. list-style: none;
  111. padding: 0;
  112. margin: 0;
  113. }
  114. .endpoints li {
  115. padding: 8px 0;
  116. border-bottom: 1px solid #eee;
  117. }
  118. .endpoints li:last-child {
  119. border-bottom: none;
  120. }
  121. a {
  122. color: #0E7C86;
  123. text-decoration: none;
  124. }
  125. a:hover {
  126. text-decoration: underline;
  127. }
  128. </style>
  129. </head>
  130. <body>
  131. <div class="container">
  132. <h1>🚀 Headless Mode</h1>
  133. <p>This RailsPress installation is running in <strong>Headless CMS mode</strong>.</p>
  134. <p>The frontend is disabled. Access your content via our powerful APIs:</p>
  135. <div class="endpoints">
  136. <h3>📡 Available APIs</h3>
  137. <ul>
  138. <li><strong>GraphQL:</strong> <code>POST #{request.base_url}/graphql</code></li>
  139. <li><strong>REST API:</strong> <code>#{request.base_url}/api/v1</code></li>
  140. <li><strong>GraphiQL Explorer:</strong> <a href="#{request.base_url}/graphiql">#{request.base_url}/graphiql</a></li>
  141. </ul>
  142. </div>
  143. <p style="margin-top: 32px;">
  144. <strong>Need to access the admin panel?</strong><br>
  145. <a href="#{request.base_url}/admin">Go to Admin Panel →</a>
  146. </p>
  147. </div>
  148. </body>
  149. </html>
  150. HTML
  151. end
  152. end

app/middleware/redirect_handler.rb

18.92% lines covered

0.0% branches covered

37 relevant lines. 7 lines covered and 30 lines missed.
28 total branches, 0 branches covered and 28 branches missed.
    
  1. 1 class RedirectHandler
  2. 1 def initialize(app)
  3. 1 @app = app
  4. end
  5. 1 def call(env)
  6. request = Rack::Request.new(env)
  7. # Skip redirect handling for:
  8. # - Admin requests
  9. # - API requests
  10. # - Asset requests
  11. # - Healthcheck requests
  12. then: 0 else: 0 return @app.call(env) if skip_redirect?(request)
  13. # Check for matching redirect
  14. redirect = find_redirect_for_path(request.path)
  15. if redirect
  16. then: 0 # Record the hit
  17. redirect.record_hit! rescue nil
  18. # Get the destination
  19. destination = redirect.destination_for(request.path)
  20. # Preserve query string
  21. then: 0 else: 0 if request.query_string.present?
  22. destination += "?#{request.query_string}"
  23. end
  24. # Return redirect response
  25. status = redirect.http_status_code
  26. headers = {
  27. 'Location' => destination,
  28. 'Content-Type' => 'text/html',
  29. 'Content-Length' => '0'
  30. }
  31. # Add cache headers for permanent redirects
  32. then: 0 if redirect.permanent?
  33. headers['Cache-Control'] = 'max-age=31536000, public'
  34. else: 0 else
  35. headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
  36. end
  37. body = ['']
  38. [status, headers, body]
  39. else
  40. else: 0 # No redirect found, continue to app
  41. @app.call(env)
  42. end
  43. end
  44. 1 private
  45. 1 def skip_redirect?(request)
  46. path = request.path
  47. # Skip admin paths
  48. then: 0 else: 0 return true if path.start_with?('/admin')
  49. # Skip API paths
  50. then: 0 else: 0 return true if path.start_with?('/api')
  51. # Skip asset paths
  52. then: 0 else: 0 return true if path.start_with?('/assets', '/packs', '/uploads')
  53. # Skip Rails paths
  54. then: 0 else: 0 return true if path.start_with?('/rails')
  55. # Skip cable/action_cable
  56. then: 0 else: 0 return true if path.start_with?('/cable')
  57. # Skip healthcheck
  58. then: 0 else: 0 return true if path == '/up'
  59. # Skip if already redirecting (prevent loops)
  60. then: 0 else: 0 return true if request.env['HTTP_X_REDIRECTED'] == 'true'
  61. false
  62. end
  63. 1 def find_redirect_for_path(path)
  64. # Normalize path
  65. then: 0 else: 0 path = path.chomp('/') if path.length > 1
  66. # Try to find exact match first
  67. redirect = Redirect.active.find_by(from_path: path)
  68. then: 0 else: 0 return redirect if redirect
  69. # Check for wildcard matches
  70. Redirect.active.each do |redirect|
  71. then: 0 else: 0 return redirect if redirect.matches?(path)
  72. end
  73. nil
  74. end
  75. end

app/models/admin_notification.rb

0.0% lines covered

100.0% branches covered

2 relevant lines. 0 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AdminNotification < ApplicationRecord
  2. end

app/models/ai_agent.rb

0.0% lines covered

100.0% branches covered

117 relevant lines. 0 lines covered and 117 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiAgent < ApplicationRecord
  2. belongs_to :ai_provider
  3. has_many :ai_usages, dependent: :destroy
  4. # Meta fields for plugin extensibility
  5. has_many :meta_fields, as: :metable, dependent: :destroy
  6. include Metable
  7. AGENT_TYPES = %w[content_summarizer post_writer comments_analyzer seo_analyzer].freeze
  8. validates :name, presence: true
  9. validates :agent_type, presence: true, inclusion: { in: AGENT_TYPES }
  10. validates :ai_provider, presence: true
  11. scope :active, -> { where(active: true) }
  12. scope :by_type, ->(type) { where(agent_type: type) }
  13. scope :ordered, -> { order(:position, :name) }
  14. after_initialize :set_defaults, if: :new_record?
  15. def full_prompt(user_input = "", context = {})
  16. parts = []
  17. # Master prompt (highest priority)
  18. parts << master_prompt if master_prompt.present?
  19. # Agent prompt
  20. parts << prompt if prompt.present?
  21. # Content guidelines
  22. parts << "Content Guidelines:\n#{content}" if content.present?
  23. # Guidelines
  24. parts << "Guidelines:\n#{guidelines}" if guidelines.present?
  25. # Rules
  26. parts << "Rules:\n#{rules}" if rules.present?
  27. # Tasks
  28. parts << "Tasks:\n#{tasks}" if tasks.present?
  29. # User input
  30. parts << "User Input: #{user_input}" if user_input.present?
  31. # Context
  32. if context.present?
  33. context_str = context.map { |k, v| "#{k}: #{v}" }.join("\n")
  34. parts << "Context:\n#{context_str}"
  35. end
  36. parts.join("\n\n")
  37. end
  38. def execute(user_input = "", context = {}, user = nil)
  39. start_time = Time.current
  40. prompt_text = full_prompt(user_input, context)
  41. executing_user = user || User.first # Fallback to first user if no user provided
  42. begin
  43. result = AiService.new(ai_provider).generate(prompt_text)
  44. response_time = Time.current - start_time
  45. # Log successful usage
  46. ai_usages.create!(
  47. user: executing_user,
  48. prompt: prompt_text,
  49. response: result.to_s,
  50. tokens_used: calculate_tokens(prompt_text, result),
  51. cost: calculate_cost(prompt_text, result),
  52. response_time: response_time,
  53. success: true,
  54. metadata: {
  55. user_input: user_input,
  56. context: context,
  57. agent_type: agent_type
  58. }
  59. )
  60. result
  61. rescue => e
  62. response_time = Time.current - start_time
  63. # Log failed usage
  64. ai_usages.create!(
  65. user: executing_user,
  66. prompt: prompt_text,
  67. response: nil,
  68. tokens_used: calculate_tokens(prompt_text, ""),
  69. cost: 0.0,
  70. response_time: response_time,
  71. success: false,
  72. error_message: e.message,
  73. metadata: {
  74. user_input: user_input,
  75. context: context,
  76. agent_type: agent_type,
  77. error_class: e.class.name
  78. }
  79. )
  80. raise e
  81. end
  82. end
  83. # Usage statistics methods
  84. def total_requests
  85. ai_usages.count
  86. end
  87. def total_tokens
  88. ai_usages.sum(:tokens_used)
  89. end
  90. def total_cost
  91. ai_usages.sum(:cost)
  92. end
  93. def requests_today
  94. ai_usages.today.count
  95. end
  96. def requests_this_month
  97. ai_usages.this_month.count
  98. end
  99. def average_response_time
  100. ai_usages.average(:response_time)&.round(2) || 0
  101. end
  102. def success_rate
  103. return 0 if ai_usages.empty?
  104. (ai_usages.successful.count.to_f / ai_usages.count * 100).round(1)
  105. end
  106. def last_used
  107. ai_usages.order(:created_at).last&.created_at
  108. end
  109. private
  110. def set_defaults
  111. self.active = true if active.nil?
  112. self.position = 0 if position.nil?
  113. end
  114. def calculate_tokens(prompt, response)
  115. # Simple token estimation: ~4 characters per token
  116. # This is a rough approximation, real implementations would use tokenizers
  117. total_text = prompt.to_s + response.to_s
  118. (total_text.length / 4.0).ceil
  119. end
  120. def calculate_cost(prompt, response)
  121. # Simple cost calculation based on tokens
  122. # This should be replaced with actual pricing from the AI provider
  123. tokens = calculate_tokens(prompt, response)
  124. case ai_provider.provider_type
  125. when 'openai'
  126. tokens * 0.00002 # Rough estimate for GPT-3.5
  127. when 'anthropic'
  128. tokens * 0.000015 # Rough estimate for Claude
  129. else
  130. tokens * 0.00001 # Default estimate
  131. end
  132. end
  133. end

app/models/ai_provider.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiProvider < ApplicationRecord
  2. has_many :ai_agents, dependent: :destroy
  3. PROVIDER_TYPES = %w[openai cohere anthropic google].freeze
  4. validates :name, presence: true
  5. validates :provider_type, presence: true, inclusion: { in: PROVIDER_TYPES }
  6. validates :api_key, presence: true
  7. validates :model_identifier, presence: true
  8. validates :max_tokens, presence: true, numericality: { greater_than: 0 }
  9. validates :temperature, presence: true, numericality: { in: 0.0..2.0 }
  10. scope :active, -> { where(active: true) }
  11. scope :by_type, ->(type) { where(provider_type: type) }
  12. scope :ordered, -> { order(:position, :name) }
  13. after_initialize :set_defaults, if: :new_record?
  14. def display_name
  15. "#{name} (#{provider_type.titleize})"
  16. end
  17. def latest_model_for_type
  18. case provider_type
  19. when 'openai'
  20. 'gpt-4o'
  21. when 'cohere'
  22. 'command-r-plus'
  23. when 'anthropic'
  24. 'claude-3-5-sonnet-20241022'
  25. when 'google'
  26. 'gemini-1.5-pro'
  27. else
  28. model_identifier
  29. end
  30. end
  31. private
  32. def set_defaults
  33. self.active = true if active.nil?
  34. self.temperature = 0.7 if temperature.nil?
  35. self.max_tokens = 4000 if max_tokens.nil?
  36. self.position = 0 if position.nil?
  37. end
  38. end

app/models/ai_usage.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiUsage < ApplicationRecord
  2. belongs_to :ai_agent
  3. belongs_to :user
  4. validates :prompt, presence: true
  5. validates :tokens_used, presence: true, numericality: { greater_than: 0 }
  6. validates :cost, presence: true, numericality: { greater_than_or_equal_to: 0 }
  7. validates :response_time, presence: true, numericality: { greater_than: 0 }
  8. validates :success, inclusion: { in: [true, false] }
  9. scope :successful, -> { where(success: true) }
  10. scope :failed, -> { where(success: false) }
  11. scope :today, -> { where(created_at: Date.current.beginning_of_day..Date.current.end_of_day) }
  12. scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
  13. scope :by_agent, ->(agent) { where(ai_agent: agent) }
  14. def self.total_tokens_for_period(start_date, end_date)
  15. where(created_at: start_date..end_date).sum(:tokens_used)
  16. end
  17. def self.total_cost_for_period(start_date, end_date)
  18. where(created_at: start_date..end_date).sum(:cost)
  19. end
  20. def self.average_response_time_for_period(start_date, end_date)
  21. where(created_at: start_date..end_date).average(:response_time)&.round(2)
  22. end
  23. def self.success_rate_for_period(start_date, end_date)
  24. period_usages = where(created_at: start_date..end_date)
  25. return 0 if period_usages.empty?
  26. (period_usages.successful.count.to_f / period_usages.count * 100).round(1)
  27. end
  28. end

app/models/analytics_audit_log.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AnalyticsAuditLog < ApplicationRecord
  3. acts_as_tenant(:tenant, optional: true)
  4. belongs_to :user, optional: true
  5. belongs_to :admin_user, class_name: 'User', optional: true
  6. validates :data_type, presence: true
  7. validates :action, presence: true
  8. validates :timestamp, presence: true
  9. scope :recent, -> { where('timestamp > ?', 30.days.ago) }
  10. scope :by_user, ->(user_id) { where(user_id: user_id) }
  11. scope :by_action, ->(action) { where(action: action) }
  12. scope :by_data_type, ->(data_type) { where(data_type: data_type) }
  13. def self.log_access(user_id, data_type, action, admin_user = nil)
  14. create!(
  15. user_id: user_id,
  16. data_type: data_type,
  17. action: action,
  18. admin_user: admin_user,
  19. timestamp: Time.current,
  20. ip_address: AnalyticsSecurityService.anonymize_ip(get_current_ip),
  21. user_agent: get_current_user_agent
  22. )
  23. end
  24. private
  25. def self.get_current_ip
  26. Thread.current[:current_request]&.remote_ip || '127.0.0.1'
  27. end
  28. def self.get_current_user_agent
  29. Thread.current[:current_request]&.user_agent || 'Unknown'
  30. end
  31. end

app/models/analytics_consent.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AnalyticsConsent < ApplicationRecord
  3. acts_as_tenant(:tenant, optional: true)
  4. belongs_to :user, optional: true
  5. validates :consent_type, presence: true
  6. validates :granted, inclusion: { in: [true, false] }
  7. validates :timestamp, presence: true
  8. scope :granted, -> { where(granted: true) }
  9. scope :denied, -> { where(granted: false) }
  10. scope :by_type, ->(type) { where(consent_type: type) }
  11. scope :recent, -> { where('timestamp > ?', 1.year.ago) }
  12. scope :by_purpose, ->(purpose) { where(purpose: purpose) }
  13. def self.get_user_consent(user_id, consent_type)
  14. recent
  15. .where(user_id: user_id, consent_type: consent_type)
  16. .order(timestamp: :desc)
  17. .first
  18. end
  19. def self.user_has_consent?(user_id, consent_type)
  20. consent = get_user_consent(user_id, consent_type)
  21. consent&.granted || false
  22. end
  23. def self.consent_rate(consent_type, period = 30.days)
  24. total_consents = where(consent_type: consent_type, timestamp: period.ago..Time.current).count
  25. granted_consents = where(consent_type: consent_type, granted: true, timestamp: period.ago..Time.current).count
  26. return 0 if total_consents.zero?
  27. (granted_consents.to_f / total_consents * 100).round(2)
  28. end
  29. end

app/models/analytics_data_deletion.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AnalyticsDataDeletion < ApplicationRecord
  3. acts_as_tenant(:tenant, optional: true)
  4. belongs_to :user, optional: true
  5. belongs_to :admin_user, class_name: 'User', optional: true
  6. validates :data_types, presence: true
  7. validates :timestamp, presence: true
  8. scope :recent, -> { where('timestamp > ?', 1.year.ago) }
  9. scope :by_user, ->(user_id) { where(user_id: user_id) }
  10. scope :by_admin, ->(admin_id) { where(admin_user_id: admin_id) }
  11. def self.log_deletion(user_id, data_types, admin_user = nil)
  12. create!(
  13. user_id: user_id,
  14. data_types: data_types,
  15. admin_user: admin_user,
  16. timestamp: Time.current
  17. )
  18. end
  19. def data_types_array
  20. case data_types
  21. when String
  22. JSON.parse(data_types) rescue [data_types]
  23. when Array
  24. data_types
  25. else
  26. [data_types]
  27. end
  28. end
  29. end

app/models/analytics_event.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsEvent < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant, optional: true)
  4. # Associations
  5. belongs_to :user, optional: true
  6. belongs_to :tenant, optional: true
  7. # Serialization
  8. serialize :properties, coder: JSON, type: Hash
  9. # Validations
  10. validates :event_name, presence: true
  11. validates :session_id, presence: true
  12. # Scopes
  13. scope :by_event_name, ->(name) { where(event_name: name) }
  14. scope :by_session, ->(session_id) { where(session_id: session_id) }
  15. scope :by_user, ->(user_id) { where(user_id: user_id) }
  16. scope :recent, -> { order(created_at: :desc) }
  17. scope :today, -> { where('created_at >= ?', Time.current.beginning_of_day) }
  18. scope :this_week, -> { where('created_at >= ?', 1.week.ago) }
  19. scope :this_month, -> { where('created_at >= ?', 1.month.ago) }
  20. # Class methods for event analytics
  21. def self.event_stats(period: :month)
  22. range = case period.to_sym
  23. when :today
  24. Time.current.beginning_of_day..Time.current.end_of_day
  25. when :week
  26. 1.week.ago..Time.current
  27. when :month
  28. 1.month.ago..Time.current
  29. when :year
  30. 1.year.ago..Time.current
  31. else
  32. 1.month.ago..Time.current
  33. end
  34. events = where(created_at: range)
  35. {
  36. total_events: events.count,
  37. unique_sessions: events.distinct.count(:session_id),
  38. top_events: events.group(:event_name).order('count_id DESC').limit(10).count(:id),
  39. events_per_session: events.count.to_f / events.distinct.count(:session_id),
  40. conversion_events: events.where(event_name: ['purchase', 'signup', 'download', 'contact']).count
  41. }
  42. end
  43. def self.track_conversion(event_name, properties = {})
  44. # Track conversion events with enhanced properties
  45. create!(
  46. event_name: event_name,
  47. properties: properties.merge({
  48. conversion: true,
  49. timestamp: Time.current.iso8601,
  50. user_agent: properties[:user_agent],
  51. referrer: properties[:referrer]
  52. }),
  53. session_id: properties[:session_id] || SecureRandom.hex(16),
  54. user_id: properties[:user_id],
  55. path: properties[:path] || '/',
  56. tenant: properties[:tenant] || ActsAsTenant.current_tenant
  57. )
  58. rescue => e
  59. Rails.logger.error "Failed to track conversion event: #{e.message}"
  60. nil
  61. end
  62. end

app/models/api_token.rb

0.0% lines covered

100.0% branches covered

71 relevant lines. 0 lines covered and 71 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApiToken < ApplicationRecord
  2. belongs_to :user
  3. # Roles
  4. ROLES = %w[public editor admin].freeze
  5. # Default permissions by role
  6. DEFAULT_PERMISSIONS = {
  7. 'public' => {
  8. 'posts' => ['read'],
  9. 'pages' => ['read'],
  10. 'categories' => ['read'],
  11. 'tags' => ['read'],
  12. 'comments' => ['read']
  13. },
  14. 'editor' => {
  15. 'posts' => ['read', 'create', 'update'],
  16. 'pages' => ['read', 'create', 'update'],
  17. 'categories' => ['read', 'create', 'update'],
  18. 'tags' => ['read', 'create', 'update'],
  19. 'comments' => ['read', 'create', 'update', 'delete'],
  20. 'media' => ['read', 'create', 'update', 'delete']
  21. },
  22. 'admin' => {
  23. 'posts' => ['read', 'create', 'update', 'delete'],
  24. 'pages' => ['read', 'create', 'update', 'delete'],
  25. 'categories' => ['read', 'create', 'update', 'delete'],
  26. 'tags' => ['read', 'create', 'update', 'delete'],
  27. 'comments' => ['read', 'create', 'update', 'delete'],
  28. 'media' => ['read', 'create', 'update', 'delete'],
  29. 'users' => ['read', 'create', 'update', 'delete'],
  30. 'settings' => ['read', 'update'],
  31. 'ai_agents' => ['read', 'execute', 'create', 'update', 'delete'],
  32. 'ai_providers' => ['read', 'create', 'update', 'delete']
  33. }
  34. }.freeze
  35. # Validations
  36. validates :name, presence: true, uniqueness: { scope: :user_id }
  37. validates :token, presence: true, uniqueness: true
  38. validates :role, presence: true, inclusion: { in: ROLES }
  39. # Scopes
  40. scope :active, -> { where(active: true) }
  41. scope :expired, -> { where('expires_at < ?', Time.current) }
  42. scope :not_expired, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }
  43. scope :by_role, ->(role) { where(role: role) }
  44. scope :recent, -> { order(created_at: :desc) }
  45. # Callbacks
  46. before_validation :generate_token, on: :create
  47. before_validation :set_default_permissions, on: :create
  48. # Instance methods
  49. def expired?
  50. expires_at.present? && expires_at < Time.current
  51. end
  52. def valid_token?
  53. active && !expired?
  54. end
  55. def touch_last_used!
  56. update_column(:last_used_at, Time.current)
  57. end
  58. def has_permission?(resource, action)
  59. return false unless valid_token?
  60. resource_permissions = permissions[resource.to_s] || []
  61. resource_permissions.include?(action.to_s)
  62. end
  63. def masked_token
  64. return nil unless token
  65. "#{token[0..7]}...#{token[-4..-1]}"
  66. end
  67. def display_role
  68. role.titleize
  69. end
  70. private
  71. def generate_token
  72. self.token ||= SecureRandom.base58(32)
  73. end
  74. def set_default_permissions
  75. self.permissions ||= DEFAULT_PERMISSIONS[role] || DEFAULT_PERMISSIONS['public']
  76. end
  77. end

app/models/application_record.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 class ApplicationRecord < ActiveRecord::Base
  2. 1 primary_abstract_class
  3. end

app/models/archived_analytics_event.rb

0.0% lines covered

100.0% branches covered

37 relevant lines. 0 lines covered and 37 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ArchivedAnalyticsEvent < ApplicationRecord
  2. # Archived analytics events for long-term storage
  3. # This model stores historical event data that has been archived
  4. acts_as_tenant(:tenant, optional: true)
  5. belongs_to :tenant, optional: true
  6. belongs_to :user, optional: true
  7. # Serialize properties as JSON
  8. serialize :properties, JSON
  9. # Scopes for filtering archived events
  10. scope :by_event_name, ->(event_name) { where(event_name: event_name) }
  11. scope :by_date_range, ->(start_date, end_date) { where(created_at: start_date..end_date) }
  12. scope :by_session, ->(session_id) { where(session_id: session_id) }
  13. scope :by_user, ->(user_id) { where(user_id: user_id) }
  14. scope :recent, -> { order(created_at: :desc) }
  15. scope :today, -> { where(created_at: Date.current.all_day) }
  16. scope :this_week, -> { where(created_at: Date.current.beginning_of_week..Date.current.end_of_week) }
  17. scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
  18. # Event statistics for archived data
  19. def self.event_stats_by_date_range(start_date, end_date)
  20. data = by_date_range(start_date, end_date)
  21. {
  22. total_events: data.count,
  23. unique_events: data.distinct.count(:event_name),
  24. top_events: data.group(:event_name).count.sort_by { |_, count| -count }.first(20),
  25. events_by_hour: data.group("strftime('%H', created_at)").count,
  26. events_by_day: data.group("date(created_at)").count,
  27. conversion_events: data.where(event_name: ['conversion', 'purchase', 'signup', 'download']).count
  28. }
  29. end
  30. def self.export_for_analysis(start_date, end_date)
  31. by_date_range(start_date, end_date).map do |event|
  32. {
  33. date: event.created_at.strftime('%Y-%m-%d'),
  34. hour: event.created_at.hour,
  35. event_name: event.event_name,
  36. properties: event.properties,
  37. session_id: event.session_id,
  38. user_id: event.user_id
  39. }
  40. end
  41. end
  42. end

app/models/archived_pageview.rb

0.0% lines covered

100.0% branches covered

47 relevant lines. 0 lines covered and 47 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ArchivedPageview < ApplicationRecord
  2. # Archived pageview data for long-term storage
  3. # This model stores historical analytics data that has been archived
  4. acts_as_tenant(:tenant, optional: true)
  5. belongs_to :tenant, optional: true
  6. # Serialize metadata as JSON
  7. serialize :metadata, JSON
  8. # Scopes for filtering archived data
  9. scope :by_date_range, ->(start_date, end_date) { where(visited_at: start_date..end_date) }
  10. scope :by_country, ->(country_code) { where(country_code: country_code) }
  11. scope :by_device, ->(device) { where(device: device) }
  12. scope :by_browser, ->(browser) { where(browser: browser) }
  13. scope :unique_visitors, -> { where(unique_visitor: true) }
  14. scope :returning_visitors, -> { where(returning_visitor: true) }
  15. scope :bots, -> { where(bot: true) }
  16. scope :consented, -> { where(consented: true) }
  17. # Statistics methods for archived data
  18. def self.stats_by_date_range(start_date, end_date)
  19. data = by_date_range(start_date, end_date)
  20. {
  21. total_pageviews: data.count,
  22. unique_visitors: data.unique_visitors.count,
  23. returning_visitors: data.returning_visitors.count,
  24. bots: data.bots.count,
  25. consented_views: data.consented.count,
  26. average_reading_time: data.average(:reading_time),
  27. average_scroll_depth: data.average(:scroll_depth),
  28. average_completion_rate: data.average(:completion_rate),
  29. top_countries: data.group(:country_name).count.sort_by { |_, count| -count }.first(10),
  30. top_devices: data.group(:device).count.sort_by { |_, count| -count }.first(10),
  31. top_browsers: data.group(:browser).count.sort_by { |_, count| -count }.first(10),
  32. top_pages: data.group(:path).count.sort_by { |_, count| -count }.first(20)
  33. }
  34. end
  35. def self.export_for_analysis(start_date, end_date)
  36. by_date_range(start_date, end_date).map do |pv|
  37. {
  38. date: pv.visited_at.strftime('%Y-%m-%d'),
  39. hour: pv.visited_at.hour,
  40. path: pv.path,
  41. title: pv.title,
  42. country: pv.country_name,
  43. device: pv.device,
  44. browser: pv.browser,
  45. unique_visitor: pv.unique_visitor,
  46. reading_time: pv.reading_time,
  47. scroll_depth: pv.scroll_depth,
  48. completion_rate: pv.completion_rate
  49. }
  50. end
  51. end
  52. end

app/models/builder_page.rb

0.0% lines covered

100.0% branches covered

123 relevant lines. 0 lines covered and 123 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderPage < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :builder_theme
  5. has_many :builder_page_sections, -> { ordered }, dependent: :destroy
  6. # Serialization
  7. serialize :settings, coder: JSON, type: Hash
  8. serialize :sections, coder: JSON, type: Hash
  9. # Validations
  10. validates :template_name, presence: true, uniqueness: { scope: :builder_theme_id }
  11. validates :page_title, presence: true
  12. validates :tenant, presence: true
  13. validates :builder_theme, presence: true
  14. # Scopes
  15. scope :ordered, -> { order(:position) }
  16. scope :published, -> { where(published: true) }
  17. scope :by_template, ->(template) { where(template_name: template) }
  18. # Callbacks
  19. before_validation :set_defaults, on: :create
  20. # Class methods
  21. def self.create_page(builder_theme, template_name, page_title, settings = {}, sections = {})
  22. position = builder_theme.builder_pages.count
  23. create!(
  24. builder_theme: builder_theme,
  25. tenant: builder_theme.tenant,
  26. template_name: template_name,
  27. page_title: page_title,
  28. settings: settings,
  29. sections: sections,
  30. position: position
  31. )
  32. end
  33. def self.initialize_default_pages(builder_theme)
  34. default_pages = [
  35. { template: 'index', title: 'Home', sections: { 'header' => {}, 'hero' => {}, 'footer' => {} } },
  36. { template: 'blog', title: 'Blog', sections: { 'header' => {}, 'post-list' => {}, 'footer' => {} } },
  37. { template: 'post', title: 'Post', sections: { 'header' => {}, 'post-content' => {}, 'comments' => {}, 'footer' => {} } },
  38. { template: 'page', title: 'Page', sections: { 'header' => {}, 'rich-text' => {}, 'footer' => {} } },
  39. { template: 'search', title: 'Search', sections: { 'header' => {}, 'search-form' => {}, 'search-results' => {}, 'footer' => {} } }
  40. ]
  41. default_pages.each do |page_data|
  42. next if builder_theme.builder_pages.exists?(template_name: page_data[:template])
  43. create_page(
  44. builder_theme,
  45. page_data[:template],
  46. page_data[:title],
  47. {},
  48. page_data[:sections]
  49. )
  50. end
  51. end
  52. # Instance methods
  53. def display_name
  54. page_title
  55. end
  56. def description
  57. "#{template_name.humanize} page with #{sections.keys.size} sections"
  58. end
  59. def section_order
  60. sections.keys
  61. end
  62. def get_setting(key, default = nil)
  63. settings[key.to_s] || default
  64. end
  65. def set_setting(key, value)
  66. self.settings = settings.merge(key.to_s => value)
  67. save!
  68. end
  69. def get_section_settings(section_id)
  70. sections[section_id.to_s] || {}
  71. end
  72. def set_section_settings(section_id, section_settings)
  73. self.sections = sections.merge(section_id.to_s => section_settings)
  74. save!
  75. end
  76. def add_section(section_id, section_settings = {})
  77. self.sections = sections.merge(section_id.to_s => section_settings)
  78. save!
  79. end
  80. def remove_section(section_id)
  81. self.sections = sections.except(section_id.to_s)
  82. save!
  83. end
  84. def reorder_sections(section_ids)
  85. new_sections = {}
  86. section_ids.each do |section_id|
  87. new_sections[section_id] = sections[section_id] if sections.key?(section_id)
  88. end
  89. self.sections = new_sections
  90. save!
  91. end
  92. def sections_data
  93. sections.map do |section_id, section_settings|
  94. {
  95. 'id' => section_id,
  96. 'type' => section_id,
  97. 'settings' => section_settings
  98. }
  99. end
  100. end
  101. def template_file_path
  102. "templates/#{template_name}.json"
  103. end
  104. def template_content
  105. # Get template content from filesystem
  106. theme_path = Rails.root.join('app', 'themes', builder_theme.theme_name.underscore)
  107. template_file = theme_path.join(template_file_path)
  108. if File.exist?(template_file)
  109. JSON.parse(File.read(template_file))
  110. else
  111. # Default template structure
  112. {
  113. 'sections' => sections,
  114. 'order' => section_order,
  115. 'settings' => settings
  116. }
  117. end
  118. end
  119. def publish!
  120. update!(published: true)
  121. end
  122. def unpublish!
  123. update!(published: false)
  124. end
  125. private
  126. def set_defaults
  127. self.settings ||= {}
  128. self.sections ||= {}
  129. self.position ||= 0
  130. self.published ||= false
  131. end
  132. end

app/models/builder_page_section.rb

0.0% lines covered

100.0% branches covered

110 relevant lines. 0 lines covered and 110 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderPageSection < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :builder_page
  5. # Serialization
  6. serialize :settings, coder: JSON, type: Hash
  7. # Validations
  8. validates :section_id, presence: true, uniqueness: { scope: :builder_page_id }
  9. validates :section_type, presence: true
  10. validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
  11. validates :settings, presence: true
  12. validates :tenant, presence: true
  13. validates :builder_page, presence: true
  14. # Scopes
  15. scope :ordered, -> { order(:position) }
  16. scope :by_type, ->(type) { where(section_type: type) }
  17. # Callbacks
  18. before_validation :set_defaults, on: :create
  19. # Class methods
  20. def self.create_section(builder_page, section_type, settings = {})
  21. section_id = "#{section_type}_#{Time.current.to_i}"
  22. position = builder_page.builder_page_sections.count
  23. create!(
  24. builder_page: builder_page,
  25. tenant: builder_page.tenant,
  26. section_id: section_id,
  27. section_type: section_type,
  28. settings: settings,
  29. position: position
  30. )
  31. end
  32. def self.reorder_sections(builder_page, section_ids)
  33. section_ids.each_with_index do |section_id, index|
  34. section = builder_page.builder_page_sections.find_by(section_id: section_id)
  35. section&.update!(position: index)
  36. end
  37. end
  38. # Instance methods
  39. def update_settings!(new_settings)
  40. update!(settings: settings.merge(new_settings.stringify_keys))
  41. end
  42. def get_setting(key, default = nil)
  43. settings[key.to_s] || default
  44. end
  45. def set_setting(key, value)
  46. self.settings = settings.merge(key.to_s => value)
  47. save!
  48. end
  49. def display_name
  50. case section_type
  51. when 'hero'
  52. 'Hero'
  53. when 'post-list'
  54. 'Blog List'
  55. when 'rich-text'
  56. 'Rich Text'
  57. when 'image'
  58. 'Image'
  59. when 'gallery'
  60. 'Image Gallery'
  61. when 'contact'
  62. 'Contact Form'
  63. when 'header'
  64. 'Header'
  65. when 'footer'
  66. 'Footer'
  67. when 'menu'
  68. 'Menu'
  69. when 'search-form'
  70. 'Search Form'
  71. when 'comments'
  72. 'Comments'
  73. when 'pagination'
  74. 'Pagination'
  75. when 'taxonomy-list'
  76. 'Category/Tag List'
  77. when 'seo-head'
  78. 'SEO Head'
  79. when 'post-content'
  80. 'Post Content'
  81. when 'related-posts'
  82. 'Related Posts'
  83. else
  84. section_type.humanize
  85. end
  86. end
  87. def description
  88. case section_type
  89. when 'hero'
  90. get_setting('heading', 'Hero section')
  91. when 'post-list'
  92. "Blog list (#{get_setting('items_per_page', 6)} items)"
  93. when 'rich-text'
  94. get_setting('content', 'Rich text content')&.truncate(50)
  95. when 'image'
  96. get_setting('alt_text', 'Image section')
  97. when 'contact'
  98. get_setting('title', 'Contact form')
  99. else
  100. "#{display_name} section"
  101. end
  102. end
  103. def section_content
  104. # Get section content from filesystem
  105. theme_path = Rails.root.join('app', 'themes', builder_page.builder_theme.theme_name.underscore)
  106. section_file = theme_path.join('sections', "#{section_type}.liquid")
  107. if File.exist?(section_file)
  108. File.read(section_file)
  109. else
  110. ''
  111. end
  112. end
  113. private
  114. def set_defaults
  115. self.settings ||= {}
  116. self.position ||= 0
  117. end
  118. end

app/models/builder_theme.rb

0.0% lines covered

100.0% branches covered

460 relevant lines. 0 lines covered and 460 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderTheme < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :user
  5. belongs_to :parent_version, class_name: 'BuilderTheme', optional: true
  6. has_many :child_versions, class_name: 'BuilderTheme', foreign_key: 'parent_version_id', dependent: :nullify
  7. has_many :builder_theme_files, dependent: :destroy
  8. has_many :builder_theme_sections, -> { ordered }, dependent: :destroy
  9. has_many :builder_pages, -> { ordered }, dependent: :destroy
  10. has_many :builder_theme_snapshots, dependent: :destroy
  11. has_many :theme_previews, dependent: :destroy
  12. has_many :theme_preview_files, dependent: :destroy
  13. # Serialization
  14. serialize :settings_data, coder: JSON, type: Hash
  15. # Validations
  16. validates :theme_name, presence: true
  17. validates :label, presence: true
  18. validates :checksum, presence: true, uniqueness: true
  19. validates :user, presence: true
  20. # Scopes
  21. scope :published, -> { where(published: true) }
  22. scope :drafts, -> { where(published: false) }
  23. scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
  24. scope :latest, -> { order(created_at: :desc) }
  25. # Callbacks
  26. before_validation :generate_checksum, on: :create
  27. after_create :initialize_default_pages
  28. # Instance methods
  29. def theme
  30. @theme ||= Theme.where("LOWER(name) = ?", theme_name.downcase).first
  31. end
  32. def has_published_version?
  33. @has_published_version ||= PublishedThemeVersion.for_theme(theme).exists?
  34. end
  35. def published_version
  36. @published_version ||= PublishedThemeVersion.for_theme(theme).latest.first
  37. end
  38. def is_theme_active?
  39. # Check if the theme this BuilderTheme belongs to is active
  40. theme&.active?
  41. end
  42. # Class methods
  43. def self.create_version(theme_name, user, parent_version = nil, label = nil)
  44. label ||= "Version #{Time.current.strftime('%Y%m%d_%H%M%S')}"
  45. # Get the actual tenant object
  46. current_tenant = ActsAsTenant.current_tenant
  47. tenant = if current_tenant.is_a?(OpenStruct)
  48. Tenant.find(current_tenant.id)
  49. else
  50. current_tenant
  51. end
  52. create!(
  53. theme_name: theme_name,
  54. label: label,
  55. parent_version: parent_version,
  56. user: user,
  57. tenant: tenant,
  58. summary: "Created new version from #{parent_version&.label || 'base'}"
  59. )
  60. end
  61. def self.current_for_theme(theme_name)
  62. published.for_theme(theme_name).latest.first
  63. end
  64. def self.draft_for_theme(theme_name)
  65. drafts.for_theme(theme_name).latest.first
  66. end
  67. # Instance methods
  68. def publish!
  69. # Unpublish other versions of the same theme
  70. self.class.for_theme(theme_name).where.not(id: id).update_all(published: false)
  71. # Publish this version
  72. update!(published: true)
  73. # Create snapshot
  74. create_snapshot!
  75. end
  76. def create_snapshot!
  77. BuilderThemeSnapshot.create!(
  78. theme_name: theme_name,
  79. builder_theme: self,
  80. settings_data: settings_data.to_json,
  81. sections_data: sections_data.to_json,
  82. user: user,
  83. tenant: tenant, # tenant is already a proper Tenant object
  84. checksum: Digest::SHA256.hexdigest("#{settings_data}#{sections_data}#{created_at}")
  85. )
  86. end
  87. def sections_data
  88. @sections_data ||= build_sections_data
  89. end
  90. def sections_data=(data)
  91. @sections_data = data
  92. end
  93. def build_sections_data
  94. sections = {}
  95. builder_theme_sections.each do |section|
  96. sections[section.section_id] = {
  97. 'type' => section.section_type,
  98. 'settings' => section.settings
  99. }
  100. end
  101. sections
  102. end
  103. def section_order
  104. builder_theme_sections.pluck(:section_id)
  105. end
  106. def add_section(section_type, settings = {})
  107. BuilderThemeSection.create_section(self, section_type, settings)
  108. end
  109. def remove_section(section_id)
  110. section = builder_theme_sections.find_by(section_id: section_id)
  111. section&.destroy!
  112. # Reorder remaining sections
  113. reorder_sections
  114. end
  115. def reorder_sections
  116. builder_theme_sections.ordered.each_with_index do |section, index|
  117. section.update!(position: index)
  118. end
  119. end
  120. def update_section_order(section_ids)
  121. BuilderThemeSection.reorder_sections(self, section_ids)
  122. end
  123. def get_section(section_id)
  124. builder_theme_sections.find_by(section_id: section_id)
  125. end
  126. # Update section settings in PublishedThemeFile
  127. def update_section_settings(section_id, settings, template_name = 'index')
  128. published_version = ensure_published_version!
  129. # Get the template file
  130. template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
  131. return false unless template_file
  132. # Parse the template content
  133. template_content = JSON.parse(template_file.content)
  134. # Update the section settings
  135. if template_content['sections'] && template_content['sections'][section_id]
  136. template_content['sections'][section_id]['settings'] = settings
  137. # Update the PublishedThemeFile content
  138. template_file.update!(
  139. content: JSON.pretty_generate(template_content),
  140. checksum: Digest::MD5.hexdigest(template_file.content)
  141. )
  142. true
  143. else
  144. false
  145. end
  146. end
  147. # Update template file with new sections order
  148. def update_template_sections(template_name, sections_hash, section_order)
  149. published_version = ensure_published_version!
  150. # Get or create the template file
  151. template_file = published_version.published_theme_files.find_or_initialize_by(file_path: "templates/#{template_name}.json")
  152. # Create the template content
  153. template_content = {
  154. 'name' => template_name.humanize,
  155. 'sections' => sections_hash,
  156. 'order' => section_order
  157. }
  158. # Update the PublishedThemeFile content
  159. template_file.assign_attributes(
  160. file_type: 'template',
  161. content: JSON.pretty_generate(template_content),
  162. checksum: Digest::MD5.hexdigest(template_file.content)
  163. )
  164. template_file.save!
  165. end
  166. # Get rendered file - creates PublishedThemeVersion if none exists, then works with PublishedThemeFile
  167. def get_rendered_file(template_name = 'index')
  168. # Ensure we have a PublishedThemeVersion to work with
  169. published_version = ensure_published_version!
  170. # Get layout file from PublishedThemeFile
  171. layout_file = published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
  172. layout_content = layout_file&.content || default_layout
  173. # Get template JSON from PublishedThemeFile
  174. template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
  175. template_content = template_file ? JSON.parse(template_file.content) : {}
  176. Rails.logger.info "Template file found: #{template_file.present?}"
  177. Rails.logger.info "Template content: #{template_content.inspect}"
  178. # Build page sections from template content
  179. page_sections = build_page_sections_from_template(template_content)
  180. Rails.logger.info "Built #{page_sections.length} page sections"
  181. # Return rendered data with PublishedThemeFile content
  182. {
  183. template_name: template_name,
  184. template_content: template_content,
  185. layout_content: layout_content,
  186. page_sections: page_sections,
  187. theme_settings: {},
  188. published_version: published_version
  189. }
  190. end
  191. # Publish the builder theme as a PublishedThemeVersion
  192. def publish!(publisher = nil)
  193. # Find or create the latest PublishedThemeVersion
  194. published_version = PublishedThemeVersion.where(theme: theme).latest.first
  195. if published_version
  196. # Update existing version
  197. published_version.update!(published_at: Time.current, published_by: publisher || user)
  198. else
  199. # Create new version
  200. published_version = PublishedThemeVersion.create!(
  201. theme: theme,
  202. version_number: next_version_number,
  203. published_at: Time.current,
  204. published_by: publisher || user,
  205. tenant: tenant
  206. )
  207. end
  208. # Copy all files from ThemesManager (database)
  209. manager = ThemesManager.new
  210. active_theme_version = manager.active_theme_version
  211. # Copy all theme files
  212. active_theme_version.theme_files.each do |theme_file|
  213. # Get the original content from ThemesManager
  214. content = manager.get_file(theme_file.file_path)
  215. next unless content
  216. # Create or update the published file
  217. published_file = PublishedThemeFile.find_or_initialize_by(
  218. published_theme_version: published_version,
  219. file_path: theme_file.file_path
  220. )
  221. published_file.assign_attributes(
  222. file_type: theme_file.file_type,
  223. content: content,
  224. checksum: Digest::MD5.hexdigest(content)
  225. )
  226. published_file.save!
  227. end
  228. # Mark as published
  229. update!(published: true, published_at: Time.current)
  230. published_version
  231. end
  232. def ensure_published_version!
  233. # Check if we have a PublishedThemeVersion for this theme
  234. published_version = PublishedThemeVersion.where(theme: theme).latest.first
  235. unless published_version
  236. Rails.logger.info "No PublishedThemeVersion found for #{theme.name}, creating initial version..."
  237. # Create initial PublishedThemeVersion
  238. published_version = PublishedThemeVersion.create!(
  239. theme: theme,
  240. version_number: 1,
  241. published_at: Time.current,
  242. published_by: user,
  243. tenant: tenant
  244. )
  245. # Copy all files from ThemesManager to PublishedThemeFile
  246. manager = ThemesManager.new
  247. active_theme_version = manager.active_theme_version
  248. if active_theme_version
  249. active_theme_version.theme_files.each do |theme_file|
  250. content = manager.get_file(theme_file.file_path)
  251. next unless content
  252. # Convert absolute path to relative path
  253. relative_path = theme_file.file_path.gsub(/^.*\/themes\/[^\/]+\//, '')
  254. PublishedThemeFile.create!(
  255. published_theme_version: published_version,
  256. file_path: relative_path,
  257. file_type: theme_file.file_type,
  258. content: content,
  259. checksum: Digest::MD5.hexdigest(content)
  260. )
  261. end
  262. Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
  263. end
  264. end
  265. published_version
  266. end
  267. private
  268. # Build page sections from template JSON content
  269. def build_page_sections_from_template(template_content)
  270. sections = []
  271. # Get the order from template content
  272. section_order = template_content['order'] || []
  273. # If no order specified, use the keys from sections
  274. if section_order.empty? && template_content['sections']
  275. section_order = template_content['sections'].keys
  276. end
  277. # Build section objects in the correct order
  278. section_order.each do |section_id|
  279. section_data = template_content['sections']&.[](section_id)
  280. next unless section_data
  281. # Create a mock section object that matches BuilderPageSection interface
  282. section = OpenStruct.new(
  283. section_id: section_id,
  284. section_type: section_data['type'] || section_id,
  285. settings: section_data['settings'] || {},
  286. position: sections.length + 1,
  287. display_name: section_data['name'] || section_id.humanize,
  288. description: section_data['description'] || ''
  289. )
  290. sections << section
  291. end
  292. sections
  293. end
  294. def next_version_number
  295. # Get the next version number for this theme
  296. last_version = PublishedThemeVersion.where(theme: theme).maximum(:version_number) || 0
  297. last_version + 1
  298. end
  299. # Sync sections from template JSON file to database
  300. def sync_page_sections_from_template(page, template_name)
  301. manager = ThemesManager.new
  302. template_data = manager.get_parsed_file("templates/#{template_name}.json")
  303. return unless template_data && template_data['sections']
  304. # Clear existing sections
  305. page.builder_page_sections.destroy_all
  306. # Handle different section formats
  307. if template_data['sections'].is_a?(Array)
  308. # Array format: [["id", {type, settings}], ...]
  309. template_data['sections'].each_with_index do |section_data, index|
  310. # Handle both array format [id, {type, settings}] and hash format {id, type, settings}
  311. if section_data.is_a?(Array) && section_data.length == 2
  312. section_id = section_data[0]
  313. section_config = section_data[1]
  314. section_type = section_config['type']
  315. section_settings = section_config['settings'] || {}
  316. elsif section_data.is_a?(Hash)
  317. section_id = section_data['id'] || "#{section_data['type']}_#{Time.current.to_i}"
  318. section_type = section_data['type']
  319. section_settings = section_data['settings'] || {}
  320. else
  321. next # Skip invalid section data
  322. end
  323. # Create the section
  324. page.builder_page_sections.create!(
  325. tenant: tenant,
  326. section_id: section_id,
  327. section_type: section_type,
  328. settings: section_settings,
  329. position: index
  330. )
  331. end
  332. elsif template_data['sections'].is_a?(Hash) && template_data['order']
  333. # Object format with order array: {sections: {id: {type, settings}}, order: ["id1", "id2"]}
  334. template_data['order'].each_with_index do |section_id, index|
  335. section_config = template_data['sections'][section_id]
  336. next unless section_config
  337. section_type = section_config['type']
  338. section_settings = section_config['settings'] || {}
  339. # Create the section
  340. page.builder_page_sections.create!(
  341. tenant: tenant,
  342. section_id: section_id,
  343. section_type: section_type,
  344. settings: section_settings,
  345. position: index
  346. )
  347. end
  348. end
  349. Rails.logger.info "Synced #{page.builder_page_sections.count} sections for #{template_name} template"
  350. end
  351. # Get all builder files for this theme
  352. def builder_files
  353. BuilderFile.where(tenant: tenant)
  354. end
  355. def settings_data
  356. @settings_data ||= load_settings_data
  357. end
  358. def settings_data=(data)
  359. @settings_data = data
  360. end
  361. def get_file(path)
  362. builder_theme_files.find_by(path: path)
  363. end
  364. def get_template(template_name)
  365. # Get the template JSON file (e.g., 'home.json', 'blog.json', etc.)
  366. template_file = get_file("templates/#{template_name}.json")
  367. return nil unless template_file
  368. begin
  369. JSON.parse(template_file.content)
  370. rescue JSON::ParserError
  371. nil
  372. end
  373. end
  374. def get_section_content(section_type)
  375. # Get the section Liquid file
  376. section_file = get_file("sections/#{section_type}.liquid")
  377. section_file&.content || ''
  378. end
  379. def get_layout_content
  380. # Get the layout Liquid file
  381. layout_file = get_file("layout/theme.liquid")
  382. layout_file&.content || ''
  383. end
  384. def update_file(path, content)
  385. file = builder_theme_files.find_or_initialize_by(path: path)
  386. file.content = content
  387. file.checksum = Digest::SHA256.hexdigest(content)
  388. file.file_size = content.bytesize
  389. # Ensure we have a proper tenant object
  390. if tenant.is_a?(OpenStruct)
  391. file.tenant = Tenant.find(tenant.tenant_id)
  392. else
  393. file.tenant = tenant
  394. end
  395. file.save!
  396. file
  397. end
  398. def file_tree
  399. @file_tree ||= build_file_tree
  400. end
  401. def can_be_published?
  402. builder_theme_files.any? && !published?
  403. end
  404. def version_number
  405. return 1 unless parent_version
  406. parent_version.version_number + 1
  407. end
  408. private
  409. def generate_checksum
  410. return if checksum.present?
  411. content = "#{theme_name}#{label}#{parent_version_id}#{Time.current.to_i}"
  412. self.checksum = Digest::SHA256.hexdigest(content)
  413. end
  414. def create_initial_files
  415. return unless parent_version.nil? # Only for root versions
  416. # Copy files from the actual theme directory
  417. theme_path = Rails.root.join('app', 'themes', theme_name)
  418. return unless Dir.exist?(theme_path)
  419. copy_theme_files(theme_path)
  420. end
  421. def copy_theme_files(theme_path, relative_path = '')
  422. Dir.entries(theme_path).each do |entry|
  423. next if entry.start_with?('.')
  424. entry_path = File.join(theme_path, entry)
  425. file_relative_path = relative_path.present? ? "#{relative_path}/#{entry}" : entry
  426. if File.directory?(entry_path)
  427. copy_theme_files(entry_path, file_relative_path)
  428. else
  429. content = File.read(entry_path)
  430. update_file(file_relative_path, content)
  431. end
  432. end
  433. end
  434. def load_sections_data
  435. # Load from template JSON files
  436. sections = {}
  437. builder_theme_files.where("path LIKE 'templates/%.json'").each do |file|
  438. begin
  439. template_data = JSON.parse(file.content)
  440. sections.merge!(template_data['sections'] || {})
  441. rescue JSON::ParserError
  442. Rails.logger.warn "Invalid JSON in template file: #{file.path}"
  443. end
  444. end
  445. sections
  446. end
  447. def load_settings_data
  448. # Load from settings schema
  449. settings = {}
  450. settings_file = builder_theme_files.find_by(path: 'config/settings_schema.json')
  451. return settings unless settings_file
  452. begin
  453. schema = JSON.parse(settings_file.content)
  454. schema.each do |group|
  455. group['settings']&.each do |setting|
  456. settings[setting['id']] = setting['default']
  457. end
  458. end
  459. rescue JSON::ParserError
  460. Rails.logger.warn "Invalid JSON in settings schema: #{settings_file.path}"
  461. end
  462. settings
  463. end
  464. def build_file_tree
  465. tree = {}
  466. builder_theme_files.each do |file|
  467. path_parts = file.path.split('/')
  468. current = tree
  469. path_parts[0..-2].each do |part|
  470. current[part] ||= { type: 'directory', children: {} }
  471. current = current[part][:children]
  472. end
  473. current[path_parts.last] = {
  474. type: 'file',
  475. path: file.path,
  476. size: file.file_size,
  477. checksum: file.checksum
  478. }
  479. end
  480. tree
  481. end
  482. def initialize_default_pages
  483. # Define default pages based on common templates
  484. default_page_templates = {
  485. 'index' => 'Home Page',
  486. 'blog' => 'Blog Page',
  487. 'post' => 'Post Page',
  488. 'page' => 'Generic Page',
  489. 'search' => 'Search Results',
  490. '404' => '404 Not Found',
  491. 'login' => 'Login Page',
  492. 'register' => 'Register Page',
  493. 'contact' => 'Contact Page',
  494. 'error' => 'Error Page',
  495. 'email' => 'Email Template',
  496. 'maintenance' => 'Maintenance Page'
  497. }
  498. default_page_templates.each do |template_name, page_title|
  499. builder_pages.find_or_create_by!(template_name: template_name) do |page|
  500. page.page_title = page_title
  501. page.settings = {} # Initial empty settings
  502. page.sections = {} # Initial empty sections (will be managed by BuilderPageSection)
  503. page.published = true if template_name == 'index' # Publish home page by default
  504. page.tenant = tenant
  505. end
  506. end
  507. end
  508. def default_layout
  509. <<~LIQUID
  510. <!DOCTYPE html>
  511. <html lang="en">
  512. <head>
  513. <meta charset="UTF-8">
  514. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  515. <title>{{ page_title | default: site.title }}</title>
  516. {{ content_for_header }}
  517. </head>
  518. <body>
  519. {{ content_for_layout }}
  520. {{ content_for_footer }}
  521. </body>
  522. </html>
  523. LIQUID
  524. end
  525. end

app/models/builder_theme_file.rb

0.0% lines covered

100.0% branches covered

93 relevant lines. 0 lines covered and 93 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderThemeFile < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :builder_theme
  5. # Validations
  6. validates :path, presence: true, uniqueness: { scope: :builder_theme_id }
  7. validates :content, presence: true
  8. validates :checksum, presence: true
  9. validates :builder_theme, presence: true
  10. # Callbacks
  11. before_validation :generate_checksum, on: :create
  12. before_validation :calculate_file_size, on: :create
  13. # Scopes
  14. scope :liquid_files, -> { where("path LIKE '%.liquid'") }
  15. scope :json_files, -> { where("path LIKE '%.json'") }
  16. scope :css_files, -> { where("path LIKE '%.css'") }
  17. scope :js_files, -> { where("path LIKE '%.js'") }
  18. scope :sections, -> { where("path LIKE 'sections/%.liquid'") }
  19. scope :templates, -> { where("path LIKE 'templates/%.json'") }
  20. scope :snippets, -> { where("path LIKE 'snippets/%.liquid'") }
  21. scope :layouts, -> { where("path LIKE 'layout/%.liquid'") }
  22. # Class methods
  23. def self.editable_extensions
  24. %w[.liquid .json .css .js .html .md .yml .yaml]
  25. end
  26. def self.editable?(path)
  27. ext = File.extname(path).downcase
  28. editable_extensions.include?(ext)
  29. end
  30. # Instance methods
  31. def editable?
  32. self.class.editable?(path)
  33. end
  34. def file_type
  35. case File.extname(path).downcase
  36. when '.liquid'
  37. 'liquid'
  38. when '.json'
  39. 'json'
  40. when '.css'
  41. 'css'
  42. when '.js'
  43. 'javascript'
  44. when '.html'
  45. 'html'
  46. when '.md'
  47. 'markdown'
  48. when '.yml', '.yaml'
  49. 'yaml'
  50. else
  51. 'text'
  52. end
  53. end
  54. def section_name
  55. return nil unless path.start_with?('sections/')
  56. File.basename(path, '.liquid')
  57. end
  58. def template_name
  59. return nil unless path.start_with?('templates/')
  60. File.basename(path, '.json')
  61. end
  62. def snippet_name
  63. return nil unless path.start_with?('snippets/')
  64. File.basename(path, '.liquid')
  65. end
  66. def layout_name
  67. return nil unless path.start_with?('layout/')
  68. File.basename(path, '.liquid')
  69. end
  70. def schema_data
  71. return nil unless file_type == 'liquid' && content.include?('{% schema %}')
  72. # Extract schema from liquid file
  73. schema_match = content.match(/{% schema %}(.*?){% endschema %}/m)
  74. return nil unless schema_match
  75. begin
  76. JSON.parse(schema_match[1].strip)
  77. rescue JSON::ParserError
  78. nil
  79. end
  80. end
  81. def update_content!(new_content)
  82. self.content = new_content
  83. generate_checksum
  84. calculate_file_size
  85. save!
  86. end
  87. def content_changed?
  88. new_checksum = Digest::SHA256.hexdigest(content)
  89. checksum != new_checksum
  90. end
  91. private
  92. def generate_checksum
  93. return if content.blank?
  94. self.checksum = Digest::SHA256.hexdigest(content)
  95. end
  96. def calculate_file_size
  97. return if content.blank?
  98. self.file_size = content.bytesize
  99. end
  100. end

app/models/builder_theme_section.rb

0.0% lines covered

100.0% branches covered

101 relevant lines. 0 lines covered and 101 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderThemeSection < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :builder_theme
  5. # Serialization
  6. serialize :settings, coder: JSON, type: Hash
  7. # Validations
  8. validates :section_id, presence: true, uniqueness: { scope: :builder_theme_id }
  9. validates :section_type, presence: true
  10. validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
  11. validates :settings, presence: true
  12. validates :tenant, presence: true
  13. validates :builder_theme, presence: true
  14. # Scopes
  15. scope :ordered, -> { order(:position) }
  16. scope :by_type, ->(type) { where(section_type: type) }
  17. # Callbacks
  18. before_validation :set_defaults, on: :create
  19. # Class methods
  20. def self.create_section(builder_theme, section_type, settings = {})
  21. section_id = "#{section_type}_#{Time.current.to_i}"
  22. position = builder_theme.builder_theme_sections.count
  23. create!(
  24. builder_theme: builder_theme,
  25. tenant: builder_theme.tenant,
  26. section_id: section_id,
  27. section_type: section_type,
  28. settings: settings,
  29. position: position
  30. )
  31. end
  32. def self.reorder_sections(builder_theme, section_ids)
  33. section_ids.each_with_index do |section_id, index|
  34. section = builder_theme.builder_theme_sections.find_by(section_id: section_id)
  35. section&.update!(position: index)
  36. end
  37. end
  38. # Instance methods
  39. def update_settings!(new_settings)
  40. update!(settings: settings.merge(new_settings.stringify_keys))
  41. end
  42. def get_setting(key, default = nil)
  43. settings[key.to_s] || default
  44. end
  45. def set_setting(key, value)
  46. self.settings = settings.merge(key.to_s => value)
  47. save!
  48. end
  49. def display_name
  50. case section_type
  51. when 'hero'
  52. 'Hero'
  53. when 'post-list'
  54. 'Blog List'
  55. when 'rich-text'
  56. 'Rich Text'
  57. when 'image'
  58. 'Image'
  59. when 'gallery'
  60. 'Image Gallery'
  61. when 'contact'
  62. 'Contact Form'
  63. when 'header'
  64. 'Header'
  65. when 'footer'
  66. 'Footer'
  67. when 'menu'
  68. 'Menu'
  69. when 'search-form'
  70. 'Search Form'
  71. when 'comments'
  72. 'Comments'
  73. when 'pagination'
  74. 'Pagination'
  75. when 'taxonomy-list'
  76. 'Category/Tag List'
  77. when 'seo-head'
  78. 'SEO Head'
  79. when 'post-content'
  80. 'Post Content'
  81. when 'related-posts'
  82. 'Related Posts'
  83. else
  84. section_type.humanize
  85. end
  86. end
  87. def description
  88. case section_type
  89. when 'hero'
  90. get_setting('heading', 'Hero section')
  91. when 'post-list'
  92. "Blog list (#{get_setting('items_per_page', 6)} items)"
  93. when 'rich-text'
  94. get_setting('content', 'Rich text content')&.truncate(50)
  95. when 'image'
  96. get_setting('alt_text', 'Image section')
  97. when 'contact'
  98. get_setting('title', 'Contact form')
  99. else
  100. "#{display_name} section"
  101. end
  102. end
  103. private
  104. def set_defaults
  105. self.settings ||= {}
  106. self.position ||= 0
  107. end
  108. end

app/models/builder_theme_snapshot.rb

0.0% lines covered

100.0% branches covered

110 relevant lines. 0 lines covered and 110 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderThemeSnapshot < ApplicationRecord
  2. # Associations
  3. belongs_to :tenant
  4. belongs_to :builder_theme
  5. belongs_to :user
  6. # Validations
  7. validates :theme_name, presence: true
  8. validates :settings_data, presence: true
  9. validates :sections_data, presence: true
  10. validates :checksum, presence: true, uniqueness: true
  11. validates :builder_theme, presence: true
  12. validates :user, presence: true
  13. # Scopes
  14. scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
  15. scope :latest, -> { order(created_at: :desc) }
  16. # Callbacks
  17. before_validation :generate_checksum, on: :create
  18. # Class methods
  19. def self.current_for_theme(theme_name)
  20. for_theme(theme_name).latest.first
  21. end
  22. def self.create_from_version(builder_theme)
  23. create!(
  24. theme_name: builder_theme.theme_name,
  25. builder_theme: builder_theme,
  26. settings_data: builder_theme.settings_data.to_json,
  27. sections_data: builder_theme.sections_data.to_json,
  28. user: builder_theme.user
  29. )
  30. end
  31. # Instance methods
  32. def settings
  33. @settings ||= JSON.parse(settings_data)
  34. rescue JSON::ParserError
  35. {}
  36. end
  37. def sections
  38. @sections ||= JSON.parse(sections_data)
  39. rescue JSON::ParserError
  40. {}
  41. end
  42. def settings=(data)
  43. @settings = data
  44. self.settings_data = data.to_json
  45. end
  46. def sections=(data)
  47. @sections = data
  48. self.sections_data = data.to_json
  49. end
  50. def get_setting(key, default = nil)
  51. settings[key.to_s] || default
  52. end
  53. def get_section(section_id)
  54. sections[section_id.to_s]
  55. end
  56. def section_order
  57. sections.keys
  58. end
  59. def apply_to_frontend!
  60. # This method would be called to apply the snapshot to the frontend
  61. # For now, we'll just log it - the actual implementation would depend
  62. # on how the frontend picks up theme changes
  63. Rails.logger.info "Applying theme snapshot #{id} for theme #{theme_name}"
  64. # In a real implementation, this might:
  65. # 1. Update a cache key
  66. # 2. Trigger a webhook
  67. # 3. Update a database flag that the frontend checks
  68. # 4. Send a message to a queue for processing
  69. # For now, we'll create a simple cache entry
  70. Rails.cache.write("theme_snapshot_#{theme_name}", id, expires_in: 1.hour)
  71. end
  72. def rollback_to!(target_snapshot)
  73. return false unless target_snapshot.theme_name == theme_name
  74. # Create a new version based on the target snapshot
  75. new_version = BuilderTheme.create_version(
  76. theme_name,
  77. user,
  78. builder_theme,
  79. "Rollback to #{target_snapshot.created_at.strftime('%Y-%m-%d %H:%M')}"
  80. )
  81. # Apply the snapshot data to the new version
  82. new_version.settings_data = target_snapshot.settings
  83. new_version.sections_data = target_snapshot.sections
  84. new_version.save!
  85. new_version
  86. end
  87. def diff_with(other_snapshot)
  88. return {} unless other_snapshot.is_a?(BuilderThemeSnapshot)
  89. {
  90. settings: diff_hash(settings, other_snapshot.settings),
  91. sections: diff_hash(sections, other_snapshot.sections)
  92. }
  93. end
  94. def created_by
  95. user.email
  96. end
  97. def version_info
  98. {
  99. id: id,
  100. theme_name: theme_name,
  101. created_at: created_at,
  102. created_by: created_by,
  103. checksum: checksum
  104. }
  105. end
  106. private
  107. def generate_checksum
  108. return if checksum.present?
  109. content = "#{settings_data}#{sections_data}#{created_at || Time.current}"
  110. self.checksum = Digest::SHA256.hexdigest(content)
  111. end
  112. def diff_hash(hash1, hash2)
  113. diff = {}
  114. # Find keys that are different or new
  115. (hash1.keys + hash2.keys).uniq.each do |key|
  116. val1 = hash1[key]
  117. val2 = hash2[key]
  118. if val1 != val2
  119. diff[key] = {
  120. from: val1,
  121. to: val2,
  122. type: val1.nil? ? 'added' : (val2.nil? ? 'removed' : 'changed')
  123. }
  124. end
  125. end
  126. diff
  127. end
  128. end

app/models/channel.rb

0.0% lines covered

100.0% branches covered

87 relevant lines. 0 lines covered and 87 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Channel < ApplicationRecord
  2. # Multi-tenancy
  3. # acts_as_tenant(:tenant, optional: true) # Temporarily disabled for testing
  4. # Associations
  5. has_and_belongs_to_many :posts
  6. has_and_belongs_to_many :pages
  7. has_and_belongs_to_many :media, class_name: 'Medium'
  8. has_many :channel_overrides, dependent: :destroy
  9. # Validations
  10. validates :name, presence: true
  11. validates :slug, presence: true, uniqueness: true
  12. validates :locale, presence: true
  13. # Scopes
  14. scope :active, -> { where(enabled: true) }
  15. scope :by_domain, ->(domain) { where(domain: domain) }
  16. scope :by_locale, ->(locale) { where(locale: locale) }
  17. # Callbacks
  18. before_validation :set_default_locale
  19. before_validation :generate_slug_from_name, if: -> { slug.blank? }
  20. # Methods
  21. def self.find_by_domain(domain)
  22. find_by(domain: domain)
  23. end
  24. def self.find_by_slug(slug)
  25. find_by(slug: slug)
  26. end
  27. def override_for(resource_type, resource_id, path)
  28. channel_overrides.find_by(
  29. resource_type: resource_type,
  30. resource_id: resource_id,
  31. path: path,
  32. enabled: true
  33. )
  34. end
  35. def overrides_for(resource_type, resource_id)
  36. channel_overrides.where(
  37. resource_type: resource_type,
  38. resource_id: resource_id,
  39. kind: 'override',
  40. enabled: true
  41. )
  42. end
  43. def exclusions_for(resource_type, resource_id)
  44. channel_overrides.where(
  45. resource_type: resource_type,
  46. resource_id: resource_id,
  47. kind: 'exclude',
  48. enabled: true
  49. )
  50. end
  51. def excluded?(resource_type, resource_id)
  52. exclusions_for(resource_type, resource_id).exists?
  53. end
  54. def apply_overrides_to_data(data, resource_type, resource_id, include_provenance = false)
  55. overrides = overrides_for(resource_type, resource_id)
  56. return data, {} if overrides.empty?
  57. result = data.deep_dup
  58. provenance = {}
  59. overrides.each do |override|
  60. path_parts = override.path.split('.')
  61. current = result
  62. # Navigate to the parent of the target key
  63. path_parts[0..-2].each do |part|
  64. current = current[part] ||= {}
  65. end
  66. # Set the final value
  67. current[path_parts.last] = override.data
  68. # Track provenance if requested
  69. if include_provenance
  70. provenance[override.path] = 'channel_override'
  71. end
  72. end
  73. if include_provenance
  74. return result, provenance
  75. else
  76. return result
  77. end
  78. end
  79. def to_liquid
  80. {
  81. 'id' => id,
  82. 'name' => name,
  83. 'slug' => slug,
  84. 'domain' => domain,
  85. 'locale' => locale,
  86. 'metadata' => metadata,
  87. 'settings' => settings
  88. }
  89. end
  90. private
  91. def set_default_locale
  92. self.locale ||= 'en'
  93. end
  94. def generate_slug_from_name
  95. self.slug = name.parameterize if name.present?
  96. end
  97. end

app/models/channel_override.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ChannelOverride < ApplicationRecord
  2. belongs_to :channel
  3. # Validations
  4. validates :resource_type, presence: true
  5. validates :kind, presence: true, inclusion: { in: %w[override exclude] }
  6. validates :path, presence: true
  7. validates :resource_id, presence: true, if: -> { resource_type.present? }
  8. # Scopes
  9. scope :overrides, -> { where(kind: 'override') }
  10. scope :exclusions, -> { where(kind: 'exclude') }
  11. scope :enabled, -> { where(enabled: true) }
  12. scope :for_resource, ->(resource_type, resource_id) { where(resource_type: resource_type, resource_id: resource_id) }
  13. scope :for_path, ->(path) { where(path: path) }
  14. # Methods
  15. def resource
  16. return nil unless resource_type.present? && resource_id.present?
  17. case resource_type
  18. when 'Post'
  19. Post.find_by(id: resource_id)
  20. when 'Page'
  21. Page.find_by(id: resource_id)
  22. when 'Medium'
  23. Medium.find_by(id: resource_id)
  24. when 'Setting'
  25. SiteSetting.find_by(id: resource_id)
  26. else
  27. resource_type.constantize.find_by(id: resource_id) rescue nil
  28. end
  29. end
  30. def resource_name
  31. resource&.title || resource&.name || "#{resource_type} ##{resource_id}"
  32. end
  33. def is_override?
  34. kind == 'override'
  35. end
  36. def is_exclusion?
  37. kind == 'exclude'
  38. end
  39. def apply_to_data(data)
  40. return data if !enabled? || !is_override?
  41. path_parts = path.split('.')
  42. current = data
  43. # Navigate to the parent of the target key
  44. path_parts[0..-2].each do |part|
  45. current = current[part] ||= {}
  46. end
  47. # Set the final value
  48. current[path_parts.last] = self.data
  49. data
  50. end
  51. def should_exclude_resource?
  52. enabled? && is_exclusion?
  53. end
  54. end

app/models/comment.rb

0.0% lines covered

100.0% branches covered

119 relevant lines. 0 lines covered and 119 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Comment < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant, optional: true)
  4. # Trash functionality
  5. include Trashable
  6. belongs_to :user, optional: true
  7. belongs_to :commentable, polymorphic: true
  8. belongs_to :parent, class_name: 'Comment', optional: true
  9. belongs_to :comment_parent, class_name: 'Comment', optional: true
  10. # Hierarchical comments (threaded)
  11. has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
  12. has_many :comment_replies, class_name: 'Comment', foreign_key: 'comment_parent_id', dependent: :destroy
  13. # Status enum
  14. enum status: {
  15. pending: 0,
  16. approved: 1,
  17. spam: 2,
  18. trash: 3
  19. }
  20. # Comment type enum
  21. enum comment_type: {
  22. comment: 'comment',
  23. pingback: 'pingback',
  24. trackback: 'trackback'
  25. }
  26. # Validations
  27. validates :content, presence: true
  28. validates :author_name, presence: true, unless: :user_id?
  29. validates :author_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, unless: :user_id?
  30. validates :status, presence: true
  31. validates :comment_type, presence: true
  32. validates :comment_approved, presence: true, inclusion: { in: %w[0 1] }
  33. validates :author_ip, presence: true
  34. validates :author_agent, presence: true
  35. # Scopes
  36. scope :approved, -> { where(status: :approved) }
  37. scope :recent, -> { order(created_at: :desc) }
  38. scope :root_comments, -> { where(parent_id: nil) }
  39. scope :comments_only, -> { where(comment_type: :comment) }
  40. scope :pingbacks, -> { where(comment_type: :pingback) }
  41. scope :trackbacks, -> { where(comment_type: :trackback) }
  42. # Callbacks
  43. after_initialize :set_defaults, if: :new_record?
  44. after_create :trigger_comment_created_hook
  45. after_update :trigger_comment_status_changed_hook, if: :saved_change_to_status?
  46. # Methods
  47. def author
  48. user&.email&.split('@')&.first || author_name
  49. end
  50. def approved?
  51. comment_approved == '1'
  52. end
  53. def pending?
  54. comment_approved == '0'
  55. end
  56. def approve!
  57. update!(comment_approved: '1', status: :approved)
  58. end
  59. def unapprove!
  60. update!(comment_approved: '0', status: :pending)
  61. end
  62. def browser_info
  63. return 'Unknown' unless author_agent.present?
  64. # Simple browser detection
  65. case author_agent.downcase
  66. when /chrome/
  67. 'Chrome'
  68. when /firefox/
  69. 'Firefox'
  70. when /safari/
  71. 'Safari'
  72. when /edge/
  73. 'Edge'
  74. when /opera/
  75. 'Opera'
  76. else
  77. 'Other'
  78. end
  79. end
  80. def is_reply?
  81. comment_parent_id.present?
  82. end
  83. def is_threaded_reply?
  84. parent_id.present?
  85. end
  86. # Convert Comment to Liquid-compatible hash
  87. def to_liquid
  88. {
  89. 'id' => id,
  90. 'content' => content,
  91. 'author' => author,
  92. 'author_name' => author_name,
  93. 'author_email' => author_email,
  94. 'author_url' => author_url,
  95. 'created_at' => created_at,
  96. 'updated_at' => updated_at,
  97. 'status' => status,
  98. 'comment_type' => comment_type,
  99. 'approved' => approved?,
  100. 'pending' => pending?,
  101. 'is_reply' => is_reply?,
  102. 'is_threaded_reply' => is_threaded_reply?,
  103. 'parent_id' => parent_id,
  104. 'comment_parent_id' => comment_parent_id,
  105. 'browser_info' => browser_info,
  106. 'user' => user,
  107. 'replies' => replies.to_a, # Convert AssociationRelation to array
  108. 'comment_replies' => comment_replies.to_a # Convert AssociationRelation to array
  109. }
  110. end
  111. # Make methods public for Liquid access
  112. public :to_liquid
  113. private
  114. def set_defaults
  115. self.status ||= :pending
  116. self.comment_type ||= :comment
  117. self.comment_approved ||= '0'
  118. self.author_ip ||= '127.0.0.1'
  119. self.author_agent ||= 'Unknown'
  120. end
  121. def trigger_comment_created_hook
  122. Railspress::PluginSystem.do_action('comment_created', self)
  123. end
  124. def trigger_comment_status_changed_hook
  125. if approved?
  126. Railspress::PluginSystem.do_action('comment_approved', self)
  127. elsif spam?
  128. Railspress::PluginSystem.do_action('comment_marked_spam', self)
  129. end
  130. end
  131. end

app/models/concerns/channel_detection.rb

0.0% lines covered

100.0% branches covered

119 relevant lines. 0 lines covered and 119 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module ChannelDetection
  3. extend ActiveSupport::Concern
  4. # Device detection patterns based on user agent strings
  5. DEVICE_PATTERNS = {
  6. mobile: [
  7. /iPhone/i, /Android/i, /Mobile/i, /BlackBerry/i, /Windows Phone/i,
  8. /Opera Mini/i, /IEMobile/i, /webOS/i, /Palm/i, /Nokia/i
  9. ],
  10. tablet: [
  11. /iPad/i, /Android.*Tablet/i, /Kindle/i, /Silk/i, /PlayBook/i,
  12. /BB10/i, /Tablet/i, /Nexus 7/i, /Nexus 10/i
  13. ],
  14. smart_tv: [
  15. /SmartTV/i, /TV/i, /Roku/i, /AppleTV/i, /AndroidTV/i, /WebOS/i,
  16. /Tizen/i, /NetCast/i, /BRAVIA/i, /Samsung/i, /LG/i
  17. ],
  18. desktop: [
  19. /Windows/i, /Macintosh/i, /Linux/i, /X11/i, /Win64/i, /WOW64/i
  20. ]
  21. }.freeze
  22. # Email client detection patterns
  23. EMAIL_CLIENT_PATTERNS = [
  24. /Outlook/i, /Gmail/i, /Apple Mail/i, /Thunderbird/i, /Mail/i,
  25. /Yahoo Mail/i, /Hotmail/i, /AOL/i, /Zimbra/i
  26. ].freeze
  27. class_methods do
  28. # Detect device type from user agent string
  29. def detect_device_type(user_agent)
  30. return :email if email_client?(user_agent)
  31. DEVICE_PATTERNS.each do |device_type, patterns|
  32. patterns.each do |pattern|
  33. return device_type if user_agent.match?(pattern)
  34. end
  35. end
  36. :desktop # Default fallback
  37. end
  38. # Check if user agent is an email client
  39. def email_client?(user_agent)
  40. EMAIL_CLIENT_PATTERNS.any? { |pattern| user_agent.match?(pattern) }
  41. end
  42. # Get appropriate channel for device type
  43. def channel_for_device(device_type)
  44. case device_type
  45. when :mobile, :tablet
  46. Channel.find_by(slug: 'mobile')
  47. when :smart_tv
  48. Channel.find_by(slug: 'smarttv')
  49. when :email
  50. Channel.find_by(slug: 'newsletter')
  51. else
  52. Channel.find_by(slug: 'web')
  53. end
  54. end
  55. # Auto-detect and return appropriate channel
  56. def auto_detect_channel(user_agent)
  57. device_type = detect_device_type(user_agent)
  58. channel_for_device(device_type)
  59. end
  60. # Get channel-specific settings for rendering
  61. def channel_settings_for_device(device_type)
  62. channel = channel_for_device(device_type)
  63. return {} unless channel
  64. channel.settings.merge(
  65. 'device_type' => device_type,
  66. 'channel_slug' => channel.slug,
  67. 'channel_name' => channel.name
  68. )
  69. end
  70. end
  71. # Instance methods for applying channel settings
  72. def apply_channel_settings(data, user_agent = nil)
  73. return data unless user_agent
  74. device_type = self.class.detect_device_type(user_agent)
  75. channel = self.class.channel_for_device(device_type)
  76. return data unless channel
  77. # Apply channel-specific overrides
  78. if respond_to?(:apply_overrides_to_data)
  79. overridden_data, provenance = apply_overrides_to_data(
  80. data,
  81. self.class.name,
  82. id,
  83. true
  84. )
  85. # Add channel context
  86. overridden_data.merge!(
  87. 'channel_context' => channel.slug,
  88. 'device_type' => device_type,
  89. 'provenance' => provenance
  90. )
  91. else
  92. data.merge!(
  93. 'channel_context' => channel.slug,
  94. 'device_type' => device_type
  95. )
  96. end
  97. overridden_data || data
  98. end
  99. # Get optimized content for specific device
  100. def content_for_device(device_type)
  101. channel = self.class.channel_for_device(device_type)
  102. return content unless channel
  103. # Apply device-specific optimizations
  104. optimized_content = content.dup
  105. case device_type
  106. when :mobile, :tablet
  107. # Mobile optimizations
  108. optimized_content = optimize_for_mobile(optimized_content)
  109. when :smart_tv
  110. # TV optimizations
  111. optimized_content = optimize_for_tv(optimized_content)
  112. when :email
  113. # Email optimizations
  114. optimized_content = optimize_for_email(optimized_content)
  115. end
  116. optimized_content
  117. end
  118. private
  119. def optimize_for_mobile(content)
  120. # Remove heavy elements, optimize images, etc.
  121. content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
  122. .gsub(/width="\d+"/i, '') # Remove width attributes
  123. .gsub(/height="\d+"/i, '') # Remove height attributes
  124. end
  125. def optimize_for_tv(content)
  126. # Optimize for large screens and remote navigation
  127. content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
  128. .gsub(/font-size:\s*\d+px/i, 'font-size: 24px') # Larger text
  129. end
  130. def optimize_for_email(content)
  131. # Email client compatibility
  132. content.gsub(/style="[^"]*"/i, '') # Remove inline styles
  133. .gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
  134. .gsub(/<\/div>/i, '</td></tr></table>')
  135. end
  136. end
  137. end

app/models/concerns/has_taxonomies.rb

0.0% lines covered

100.0% branches covered

71 relevant lines. 0 lines covered and 71 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module HasTaxonomies
  2. extend ActiveSupport::Concern
  3. included do
  4. has_many :term_relationships, as: :object, dependent: :destroy
  5. has_many :terms, through: :term_relationships
  6. end
  7. class_methods do
  8. # Register a taxonomy for this model
  9. def has_taxonomy(taxonomy_slug, options = {})
  10. taxonomy_name = options[:taxonomy_name] || taxonomy_slug.to_s.pluralize
  11. # Define association
  12. has_many :"#{taxonomy_slug}_relationships",
  13. -> { joins(:term).where(terms: { taxonomy_id: Taxonomy.find_by(slug: taxonomy_slug)&.id }) },
  14. class_name: 'TermRelationship',
  15. as: :object,
  16. dependent: :destroy
  17. has_many taxonomy_slug.to_sym,
  18. through: :"#{taxonomy_slug}_relationships",
  19. source: :term
  20. # Define helper methods
  21. define_method "#{taxonomy_slug}_list" do
  22. send(taxonomy_slug).pluck(:name).join(', ')
  23. end
  24. define_method "#{taxonomy_slug}_list=" do |names|
  25. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  26. return unless taxonomy
  27. term_names = names.split(',').map(&:strip).reject(&:blank?)
  28. new_terms = term_names.map do |name|
  29. taxonomy.terms.find_or_create_by!(name: name)
  30. end
  31. send("#{taxonomy_slug}=", new_terms)
  32. end
  33. end
  34. end
  35. # Instance methods
  36. # Get all terms for a specific taxonomy
  37. def terms_for_taxonomy(taxonomy_slug)
  38. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  39. return Term.none unless taxonomy
  40. terms.where(taxonomy_id: taxonomy.id)
  41. end
  42. # Set terms for a taxonomy
  43. def set_terms_for_taxonomy(taxonomy_slug, term_ids)
  44. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  45. return unless taxonomy
  46. # Remove existing terms for this taxonomy
  47. term_relationships.joins(:term)
  48. .where(terms: { taxonomy_id: taxonomy.id })
  49. .destroy_all
  50. # Add new terms
  51. Array(term_ids).each do |term_id|
  52. term = taxonomy.terms.find_by(id: term_id)
  53. terms << term if term && !terms.include?(term)
  54. end
  55. end
  56. # Add a single term
  57. def add_term(term_or_name, taxonomy_slug)
  58. taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
  59. return unless taxonomy
  60. term = if term_or_name.is_a?(Term)
  61. term_or_name
  62. else
  63. taxonomy.terms.find_or_create_by!(name: term_or_name)
  64. end
  65. terms << term unless terms.include?(term)
  66. end
  67. # Remove a term
  68. def remove_term(term)
  69. terms.delete(term)
  70. end
  71. # Check if has term
  72. def has_term?(term_or_slug)
  73. if term_or_slug.is_a?(Term)
  74. terms.include?(term_or_slug)
  75. else
  76. terms.exists?(slug: term_or_slug)
  77. end
  78. end
  79. # Get term names for taxonomy
  80. def term_names_for(taxonomy_slug)
  81. terms_for_taxonomy(taxonomy_slug).pluck(:name)
  82. end
  83. end

app/models/concerns/metable.rb

40.3% lines covered

0.0% branches covered

67 relevant lines. 27 lines covered and 40 lines missed.
15 total branches, 0 branches covered and 15 branches missed.
    
  1. 1 module Metable
  2. 1 extend ActiveSupport::Concern
  3. 1 included do
  4. # This will be added by the has_many :meta_fields association
  5. end
  6. # Convenience methods for meta fields
  7. 1 def get_meta(key)
  8. MetaField.get(self, key)
  9. end
  10. 1 def set_meta(key, value, immutable: false)
  11. MetaField.set(self, key, value, immutable: immutable)
  12. end
  13. 1 def delete_meta(key)
  14. MetaField.delete(self, key)
  15. end
  16. 1 def bulk_get_meta(keys)
  17. MetaField.bulk_get(self, keys)
  18. end
  19. 1 def bulk_set_meta(hash, immutable: false)
  20. MetaField.bulk_set(self, hash, immutable: immutable)
  21. end
  22. 1 def all_meta
  23. MetaField.all_for(self)
  24. end
  25. 1 def has_meta?(key)
  26. get_meta(key).present?
  27. end
  28. 1 def meta_keys
  29. meta_fields.pluck(:key)
  30. end
  31. 1 def immutable_meta_keys
  32. meta_fields.immutable.pluck(:key)
  33. end
  34. 1 def mutable_meta_keys
  35. meta_fields.mutable.pluck(:key)
  36. end
  37. # Plugin helpers for common use cases
  38. 1 def get_meta_as_string(key, default = "")
  39. value = get_meta(key)
  40. then: 0 else: 0 value.present? ? value.to_s : default
  41. end
  42. 1 def get_meta_as_integer(key, default = 0)
  43. value = get_meta(key)
  44. then: 0 else: 0 value.present? ? value.to_i : default
  45. end
  46. 1 def get_meta_as_float(key, default = 0.0)
  47. value = get_meta(key)
  48. then: 0 else: 0 value.present? ? value.to_f : default
  49. end
  50. 1 def get_meta_as_boolean(key, default = false)
  51. value = get_meta(key)
  52. then: 0 else: 0 return default if value.blank?
  53. case value.to_s.downcase
  54. when: 0 when 'true', '1', 'yes', 'on'
  55. true
  56. when: 0 when 'false', '0', 'no', 'off'
  57. false
  58. else: 0 else
  59. default
  60. end
  61. end
  62. 1 def get_meta_as_json(key, default = {})
  63. value = get_meta(key)
  64. then: 0 else: 0 return default if value.blank?
  65. begin
  66. JSON.parse(value)
  67. rescue JSON::ParserError
  68. default
  69. end
  70. end
  71. 1 def set_meta_json(key, value, immutable: false)
  72. set_meta(key, value.to_json, immutable: immutable)
  73. end
  74. # Clear all meta fields (useful for cleanup)
  75. 1 def clear_all_meta!
  76. meta_fields.mutable.destroy_all
  77. end
  78. # Plugin namespace helpers
  79. 1 def get_plugin_meta(plugin_name, key)
  80. get_meta("#{plugin_name}:#{key}")
  81. end
  82. 1 def set_plugin_meta(plugin_name, key, value, immutable: false)
  83. set_meta("#{plugin_name}:#{key}", value, immutable: immutable)
  84. end
  85. 1 def delete_plugin_meta(plugin_name, key)
  86. delete_meta("#{plugin_name}:#{key}")
  87. end
  88. 1 def bulk_get_plugin_meta(plugin_name, keys)
  89. prefixed_keys = keys.map { |key| "#{plugin_name}:#{key}" }
  90. values = bulk_get_meta(prefixed_keys)
  91. keys.zip(values).to_h
  92. end
  93. 1 def bulk_set_plugin_meta(plugin_name, hash, immutable: false)
  94. prefixed_hash = hash.transform_keys { |key| "#{plugin_name}:#{key}" }
  95. bulk_set_meta(prefixed_hash, immutable: immutable)
  96. end
  97. 1 def get_all_plugin_meta(plugin_name)
  98. all_meta.select { |key, _| key.start_with?("#{plugin_name}:") }
  99. .transform_keys { |key| key.sub("#{plugin_name}:", "") }
  100. then: 0 else: 0 .transform_values { |meta_data| meta_data.is_a?(Hash) ? meta_data[:value] : meta_data }
  101. end
  102. 1 def delete_all_plugin_meta(plugin_name)
  103. meta_fields.where("key LIKE ?", "#{plugin_name}:%").destroy_all
  104. end
  105. end

app/models/concerns/railspress/channel_detection.rb

0.0% lines covered

100.0% branches covered

119 relevant lines. 0 lines covered and 119 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module ChannelDetection
  3. extend ActiveSupport::Concern
  4. # Device detection patterns based on user agent strings
  5. DEVICE_PATTERNS = {
  6. mobile: [
  7. /iPhone/i, /Android/i, /Mobile/i, /BlackBerry/i, /Windows Phone/i,
  8. /Opera Mini/i, /IEMobile/i, /webOS/i, /Palm/i, /Nokia/i
  9. ],
  10. tablet: [
  11. /iPad/i, /Android.*Tablet/i, /Kindle/i, /Silk/i, /PlayBook/i,
  12. /BB10/i, /Tablet/i, /Nexus 7/i, /Nexus 10/i
  13. ],
  14. smart_tv: [
  15. /SmartTV/i, /TV/i, /Roku/i, /AppleTV/i, /AndroidTV/i, /WebOS/i,
  16. /Tizen/i, /NetCast/i, /BRAVIA/i, /Samsung/i, /LG/i
  17. ],
  18. desktop: [
  19. /Windows/i, /Macintosh/i, /Linux/i, /X11/i, /Win64/i, /WOW64/i
  20. ]
  21. }.freeze
  22. # Email client detection patterns
  23. EMAIL_CLIENT_PATTERNS = [
  24. /Outlook/i, /Gmail/i, /Apple Mail/i, /Thunderbird/i, /Mail/i,
  25. /Yahoo Mail/i, /Hotmail/i, /AOL/i, /Zimbra/i
  26. ].freeze
  27. class_methods do
  28. # Detect device type from user agent string
  29. def detect_device_type(user_agent)
  30. return :email if email_client?(user_agent)
  31. DEVICE_PATTERNS.each do |device_type, patterns|
  32. patterns.each do |pattern|
  33. return device_type if user_agent.match?(pattern)
  34. end
  35. end
  36. :desktop # Default fallback
  37. end
  38. # Check if user agent is an email client
  39. def email_client?(user_agent)
  40. EMAIL_CLIENT_PATTERNS.any? { |pattern| user_agent.match?(pattern) }
  41. end
  42. # Get appropriate channel for device type
  43. def channel_for_device(device_type)
  44. case device_type
  45. when :mobile, :tablet
  46. Channel.find_by(slug: 'mobile')
  47. when :smart_tv
  48. Channel.find_by(slug: 'smarttv')
  49. when :email
  50. Channel.find_by(slug: 'newsletter')
  51. else
  52. Channel.find_by(slug: 'web')
  53. end
  54. end
  55. # Auto-detect and return appropriate channel
  56. def auto_detect_channel(user_agent)
  57. device_type = detect_device_type(user_agent)
  58. channel_for_device(device_type)
  59. end
  60. # Get channel-specific settings for rendering
  61. def channel_settings_for_device(device_type)
  62. channel = channel_for_device(device_type)
  63. return {} unless channel
  64. channel.settings.merge(
  65. 'device_type' => device_type,
  66. 'channel_slug' => channel.slug,
  67. 'channel_name' => channel.name
  68. )
  69. end
  70. end
  71. # Instance methods for applying channel settings
  72. def apply_channel_settings(data, user_agent = nil)
  73. return data unless user_agent
  74. device_type = self.class.detect_device_type(user_agent)
  75. channel = self.class.channel_for_device(device_type)
  76. return data unless channel
  77. # Apply channel-specific overrides
  78. if respond_to?(:apply_overrides_to_data)
  79. overridden_data, provenance = apply_overrides_to_data(
  80. data,
  81. self.class.name,
  82. id,
  83. true
  84. )
  85. # Add channel context
  86. overridden_data.merge!(
  87. 'channel_context' => channel.slug,
  88. 'device_type' => device_type,
  89. 'provenance' => provenance
  90. )
  91. else
  92. data.merge!(
  93. 'channel_context' => channel.slug,
  94. 'device_type' => device_type
  95. )
  96. end
  97. overridden_data || data
  98. end
  99. # Get optimized content for specific device
  100. def content_for_device(device_type)
  101. channel = self.class.channel_for_device(device_type)
  102. return content unless channel
  103. # Apply device-specific optimizations
  104. optimized_content = content.dup
  105. case device_type
  106. when :mobile, :tablet
  107. # Mobile optimizations
  108. optimized_content = optimize_for_mobile(optimized_content)
  109. when :smart_tv
  110. # TV optimizations
  111. optimized_content = optimize_for_tv(optimized_content)
  112. when :email
  113. # Email optimizations
  114. optimized_content = optimize_for_email(optimized_content)
  115. end
  116. optimized_content
  117. end
  118. private
  119. def optimize_for_mobile(content)
  120. # Remove heavy elements, optimize images, etc.
  121. content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
  122. .gsub(/width="\d+"/i, '') # Remove width attributes
  123. .gsub(/height="\d+"/i, '') # Remove height attributes
  124. end
  125. def optimize_for_tv(content)
  126. # Optimize for large screens and remote navigation
  127. content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
  128. .gsub(/font-size:\s*\d+px/i, 'font-size: 24px') # Larger text
  129. end
  130. def optimize_for_email(content)
  131. # Email client compatibility
  132. content.gsub(/style="[^"]*"/i, '') # Remove inline styles
  133. .gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
  134. .gsub(/<\/div>/i, '</td></tr></table>')
  135. end
  136. end
  137. end

app/models/concerns/sanitizable.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Sanitizable
  2. extend ActiveSupport::Concern
  3. included do
  4. # Define which attributes should be sanitized
  5. class_attribute :sanitizable_attributes
  6. self.sanitizable_attributes = []
  7. before_validation :sanitize_content_attributes
  8. end
  9. class_methods do
  10. # Define attributes that should be sanitized
  11. # Example: sanitize_content :body, :excerpt
  12. def sanitize_content(*attributes)
  13. self.sanitizable_attributes += attributes.map(&:to_s)
  14. end
  15. end
  16. private
  17. def sanitize_content_attributes
  18. self.class.sanitizable_attributes.each do |attribute|
  19. next unless respond_to?(attribute)
  20. next if send(attribute).blank?
  21. # Get the current value
  22. value = send(attribute)
  23. # Skip if it's ActionText (already handled)
  24. next if value.is_a?(ActionText::RichText)
  25. # Sanitize the content
  26. sanitized = Railspress::HtmlSanitizer.sanitize_content(value.to_s)
  27. # Set the sanitized value
  28. send("#{attribute}=", sanitized)
  29. end
  30. end
  31. end

app/models/concerns/seo_optimizable.rb

0.0% lines covered

100.0% branches covered

89 relevant lines. 0 lines covered and 89 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module SeoOptimizable
  2. extend ActiveSupport::Concern
  3. included do
  4. # Callbacks
  5. before_validation :set_default_seo_fields
  6. # Validations
  7. validates :meta_description, length: { maximum: 160 }, allow_blank: true
  8. validates :og_description, length: { maximum: 200 }, allow_blank: true
  9. validates :twitter_description, length: { maximum: 200 }, allow_blank: true
  10. end
  11. # SEO meta title (falls back to title)
  12. def seo_title
  13. meta_title.presence || title
  14. end
  15. # SEO meta description (falls back to excerpt)
  16. def seo_description
  17. meta_description.presence || excerpt.presence || title
  18. end
  19. # Open Graph title (falls back to meta_title or title)
  20. def seo_og_title
  21. og_title.presence || meta_title.presence || title
  22. end
  23. # Open Graph description (falls back to meta_description or excerpt)
  24. def seo_og_description
  25. og_description.presence || meta_description.presence || excerpt.presence || title
  26. end
  27. # Open Graph image URL
  28. def seo_og_image
  29. og_image_url.presence || (featured_image_url if respond_to?(:featured_image_url))
  30. end
  31. # Twitter card title
  32. def seo_twitter_title
  33. twitter_title.presence || seo_og_title
  34. end
  35. # Twitter card description
  36. def seo_twitter_description
  37. twitter_description.presence || seo_og_description
  38. end
  39. # Twitter card image
  40. def seo_twitter_image
  41. twitter_image_url.presence || seo_og_image
  42. end
  43. # Canonical URL (falls back to generated URL)
  44. def seo_canonical_url
  45. canonical_url.presence || seo_default_url
  46. end
  47. # Robots meta tag
  48. def seo_robots
  49. robots_meta.presence || 'index, follow'
  50. end
  51. # Generate structured data (Schema.org)
  52. def structured_data
  53. {
  54. "@context": "https://schema.org",
  55. "@type": schema_type.presence || default_schema_type,
  56. "headline": seo_title,
  57. "description": seo_description,
  58. "image": seo_og_image,
  59. "datePublished": published_at&.iso8601,
  60. "dateModified": updated_at&.iso8601,
  61. "author": author_structured_data,
  62. "publisher": publisher_structured_data,
  63. "url": seo_canonical_url,
  64. "keywords": meta_keywords
  65. }.compact
  66. end
  67. private
  68. def set_default_seo_fields
  69. # Auto-generate meta fields if not set
  70. self.meta_title ||= title if title.present?
  71. self.meta_description ||= generate_meta_description if respond_to?(:content)
  72. self.canonical_url ||= seo_default_url
  73. end
  74. def generate_meta_description
  75. return excerpt if respond_to?(:excerpt) && excerpt.present?
  76. return nil unless respond_to?(:content) && content.present?
  77. # Extract plain text and truncate
  78. plain_text = content.to_plain_text
  79. plain_text.truncate(160, separator: ' ', omission: '...')
  80. end
  81. def seo_default_url
  82. # Override in model if needed
  83. "#"
  84. end
  85. def default_schema_type
  86. self.class.name # 'Post' or 'Page'
  87. end
  88. def author_structured_data
  89. return nil unless respond_to?(:user) && user.present?
  90. {
  91. "@type": "Person",
  92. "name": user.email,
  93. "url": "#"
  94. }
  95. end
  96. def publisher_structured_data
  97. {
  98. "@type": "Organization",
  99. "name": SiteSetting.get('site_title', 'RailsPress'),
  100. "url": Rails.application.routes.url_helpers.root_url
  101. }
  102. rescue
  103. nil
  104. end
  105. end

app/models/concerns/trashable.rb

0.0% lines covered

100.0% branches covered

47 relevant lines. 0 lines covered and 47 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Trashable
  2. extend ActiveSupport::Concern
  3. included do
  4. # Scopes
  5. scope :kept, -> { where(deleted_at: nil) }
  6. scope :trashed, -> { where.not(deleted_at: nil) }
  7. scope :trashed_before, ->(date) { where('deleted_at < ?', date) }
  8. # Associations
  9. belongs_to :trashed_by, class_name: 'User', optional: true
  10. end
  11. # Instance methods
  12. def trashed?
  13. deleted_at.present?
  14. end
  15. def kept?
  16. deleted_at.nil?
  17. end
  18. def trash!(user = nil)
  19. update!(
  20. deleted_at: Time.current,
  21. trashed_by: user
  22. )
  23. # Trigger plugin hook
  24. Railspress::PluginSystem.do_action("#{self.class.name.downcase}_trashed", self)
  25. end
  26. def untrash!
  27. update!(
  28. deleted_at: nil,
  29. trashed_by: nil
  30. )
  31. # Trigger plugin hook
  32. Railspress::PluginSystem.do_action("#{self.class.name.downcase}_untrashed", self)
  33. end
  34. def destroy_permanently!
  35. # Trigger plugin hook before permanent deletion
  36. Railspress::PluginSystem.do_action("#{self.class.name.downcase}_permanently_deleted", self)
  37. super
  38. end
  39. # Class methods
  40. class_methods do
  41. def cleanup_trash!
  42. settings = TrashSetting.current
  43. return unless settings.auto_cleanup_enabled?
  44. threshold = settings.cleanup_threshold
  45. trashed_before(threshold).find_each(&:destroy_permanently!)
  46. end
  47. def trash_count
  48. trashed.count
  49. end
  50. def kept_count
  51. kept.count
  52. end
  53. end
  54. end

app/models/consent_configuration.rb

0.0% lines covered

100.0% branches covered

508 relevant lines. 0 lines covered and 508 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ConsentConfiguration < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. # Serialization
  4. serialize :consent_categories, coder: JSON, type: Hash
  5. serialize :pixel_consent_mapping, coder: JSON, type: Hash
  6. serialize :banner_settings, coder: JSON, type: Hash
  7. serialize :geolocation_settings, coder: JSON, type: Hash
  8. # Validations
  9. validates :name, presence: true
  10. validates :banner_type, inclusion: { in: %w[bottom_banner modal overlay] }
  11. validates :consent_mode, inclusion: { in: %w[opt_in opt_out implied] }
  12. # Default consent categories
  13. DEFAULT_CONSENT_CATEGORIES = {
  14. 'necessary' => {
  15. 'name' => 'Necessary Cookies',
  16. 'description' => 'These cookies are essential for the website to function and cannot be switched off.',
  17. 'required' => true,
  18. 'default_enabled' => true,
  19. 'pixels' => []
  20. },
  21. 'analytics' => {
  22. 'name' => 'Analytics Cookies',
  23. 'description' => 'These cookies help us understand how visitors interact with our website.',
  24. 'required' => false,
  25. 'default_enabled' => false,
  26. 'pixels' => ['google_analytics', 'google_tag_manager', 'clarity', 'hotjar']
  27. },
  28. 'marketing' => {
  29. 'name' => 'Marketing Cookies',
  30. 'description' => 'These cookies are used to track visitors across websites for advertising purposes.',
  31. 'required' => false,
  32. 'default_enabled' => false,
  33. 'pixels' => ['facebook_pixel', 'tiktok_pixel', 'linkedin_insight', 'twitter_pixel', 'pinterest_tag', 'snapchat_pixel', 'reddit_pixel']
  34. },
  35. 'functional' => {
  36. 'name' => 'Functional Cookies',
  37. 'description' => 'These cookies enable enhanced functionality and personalization.',
  38. 'required' => false,
  39. 'default_enabled' => false,
  40. 'pixels' => ['mixpanel', 'segment', 'heap']
  41. }
  42. }.freeze
  43. # Default banner settings
  44. DEFAULT_BANNER_SETTINGS = {
  45. 'enabled' => true,
  46. 'position' => 'bottom',
  47. 'theme' => 'dark',
  48. 'show_manage_preferences' => true,
  49. 'show_reject_all' => true,
  50. 'show_accept_all' => true,
  51. 'show_necessary_only' => true,
  52. 'auto_hide_after_accept' => true,
  53. 'auto_hide_delay' => 3000,
  54. 'animation_duration' => 300,
  55. 'custom_css' => '',
  56. 'text' => {
  57. 'title' => 'We use cookies to enhance your experience',
  58. 'description' => 'We use cookies and similar technologies to provide, protect, and improve our services and to show you relevant content and ads.',
  59. 'accept_all' => 'Accept All',
  60. 'reject_all' => 'Reject All',
  61. 'necessary_only' => 'Necessary Only',
  62. 'manage_preferences' => 'Manage Preferences',
  63. 'save_preferences' => 'Save Preferences',
  64. 'close' => 'Close'
  65. },
  66. 'colors' => {
  67. 'primary' => '#3b82f6',
  68. 'secondary' => '#6b7280',
  69. 'background' => '#1f2937',
  70. 'text' => '#ffffff',
  71. 'button_accept' => '#10b981',
  72. 'button_reject' => '#ef4444',
  73. 'button_neutral' => '#6b7280'
  74. },
  75. 'fonts' => {
  76. 'family' => 'system-ui, -apple-system, sans-serif',
  77. 'size_title' => '18px',
  78. 'size_description' => '14px',
  79. 'size_button' => '14px'
  80. }
  81. }.freeze
  82. # Default geolocation settings
  83. DEFAULT_GEOLOCATION_SETTINGS = {
  84. 'enabled' => true,
  85. 'eu_countries' => %w[AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE],
  86. 'us_states' => %w[CA CO CT DE HI IL IA ME MD MA MI MN NH NJ NM NY OR PA RI TX UT VT VA WA],
  87. 'uk_countries' => %w[GB],
  88. 'canada_provinces' => %w[AB BC MB NB NL NS NT NU ON PE QC SK YT],
  89. 'auto_detect' => true,
  90. 'fallback_consent_mode' => 'opt_in',
  91. 'region_specific_settings' => {
  92. 'eu' => {
  93. 'consent_mode' => 'opt_in',
  94. 'show_detailed_preferences' => true,
  95. 'require_explicit_consent' => true
  96. },
  97. 'us' => {
  98. 'consent_mode' => 'opt_out',
  99. 'show_detailed_preferences' => false,
  100. 'require_explicit_consent' => false
  101. },
  102. 'uk' => {
  103. 'consent_mode' => 'opt_in',
  104. 'show_detailed_preferences' => true,
  105. 'require_explicit_consent' => true
  106. },
  107. 'ca' => {
  108. 'consent_mode' => 'opt_in',
  109. 'show_detailed_preferences' => true,
  110. 'require_explicit_consent' => true
  111. }
  112. }
  113. }.freeze
  114. # Callbacks
  115. after_initialize :set_defaults, if: :new_record?
  116. # Scopes
  117. scope :active, -> { where(active: true) }
  118. scope :by_banner_type, ->(type) { where(banner_type: type) }
  119. scope :ordered, -> { order(:name) }
  120. # Instance methods
  121. def consent_categories_with_defaults
  122. DEFAULT_CONSENT_CATEGORIES.merge(consent_categories || {})
  123. end
  124. def banner_settings_with_defaults
  125. DEFAULT_BANNER_SETTINGS.merge(banner_settings || {})
  126. end
  127. def geolocation_settings_with_defaults
  128. DEFAULT_GEOLOCATION_SETTINGS.merge(geolocation_settings || {})
  129. end
  130. def pixel_consent_mapping_with_defaults
  131. mapping = {}
  132. consent_categories_with_defaults.each do |category, settings|
  133. mapping[category] = settings['pixels'] || []
  134. end
  135. mapping.merge(pixel_consent_mapping || {})
  136. end
  137. def get_pixels_for_consent_category(category)
  138. pixel_consent_mapping_with_defaults[category] || []
  139. end
  140. def get_consent_categories_for_pixel(pixel_type)
  141. categories = []
  142. pixel_consent_mapping_with_defaults.each do |category, pixels|
  143. categories << category if pixels.include?(pixel_type)
  144. end
  145. categories
  146. end
  147. def is_pixel_consent_required?(pixel_type)
  148. get_consent_categories_for_pixel(pixel_type).any? do |category|
  149. settings = consent_categories_with_defaults[category]
  150. settings && !settings['required'] && !settings['default_enabled']
  151. end
  152. end
  153. def get_region_from_ip(ip_address)
  154. return 'unknown' unless geolocation_settings_with_defaults['enabled']
  155. begin
  156. # Use MaxMind GeoIP or similar service
  157. result = Geocoder.search(ip_address).first
  158. return 'unknown' unless result
  159. country_code = result.country_code&.upcase
  160. return 'unknown' unless country_code
  161. # Check EU countries
  162. if geolocation_settings_with_defaults['eu_countries'].include?(country_code)
  163. return 'eu'
  164. end
  165. # Check UK
  166. if geolocation_settings_with_defaults['uk_countries'].include?(country_code)
  167. return 'uk'
  168. end
  169. # Check Canada
  170. if country_code == 'CA'
  171. return 'ca'
  172. end
  173. # Check US
  174. if country_code == 'US'
  175. return 'us'
  176. end
  177. 'other'
  178. rescue => e
  179. Rails.logger.error "Geolocation error: #{e.message}"
  180. 'unknown'
  181. end
  182. end
  183. def get_consent_mode_for_region(region)
  184. region_settings = geolocation_settings_with_defaults['region_specific_settings']
  185. region_settings[region]&.dig('consent_mode') || geolocation_settings_with_defaults['fallback_consent_mode']
  186. end
  187. def should_show_banner?(region = nil, user_consent = nil)
  188. return false unless banner_settings_with_defaults['enabled']
  189. return false if user_consent&.any? { |consent| consent['consent_type'] == 'necessary' && consent['granted'] }
  190. # Check if user has given any consent
  191. if user_consent&.any? { |consent| consent['granted'] }
  192. return false
  193. end
  194. # Region-specific logic
  195. if region && region != 'unknown'
  196. region_settings = geolocation_settings_with_defaults['region_specific_settings'][region]
  197. return region_settings&.dig('require_explicit_consent') == true if region_settings
  198. end
  199. true
  200. end
  201. def generate_banner_html(region = nil, user_consent = nil)
  202. return '' unless should_show_banner?(region, user_consent)
  203. settings = banner_settings_with_defaults
  204. categories = consent_categories_with_defaults
  205. # Generate the banner HTML
  206. <<~HTML
  207. <div id="consent-banner" class="consent-banner" style="display: none;">
  208. <div class="consent-banner-content">
  209. <div class="consent-banner-header">
  210. <h3 class="consent-banner-title">#{settings['text']['title']}</h3>
  211. <button class="consent-banner-close" onclick="ConsentManager.hideBanner()">
  212. <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
  213. <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
  214. </svg>
  215. </button>
  216. </div>
  217. <div class="consent-banner-body">
  218. <p class="consent-banner-description">#{settings['text']['description']}</p>
  219. </div>
  220. <div class="consent-banner-actions">
  221. #{generate_banner_buttons(settings)}
  222. </div>
  223. </div>
  224. </div>
  225. <div id="consent-preferences-modal" class="consent-preferences-modal" style="display: none;">
  226. <div class="consent-modal-content">
  227. <div class="consent-modal-header">
  228. <h3 class="consent-modal-title">Cookie Preferences</h3>
  229. <button class="consent-modal-close" onclick="ConsentManager.hidePreferencesModal()">
  230. <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
  231. <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414 1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
  232. </svg>
  233. </button>
  234. </div>
  235. <div class="consent-modal-body">
  236. #{generate_preferences_form(categories)}
  237. </div>
  238. <div class="consent-modal-actions">
  239. <button class="consent-btn consent-btn-secondary" onclick="ConsentManager.hidePreferencesModal()">
  240. #{settings['text']['close']}
  241. </button>
  242. <button class="consent-btn consent-btn-primary" onclick="ConsentManager.savePreferences()">
  243. #{settings['text']['save_preferences']}
  244. </button>
  245. </div>
  246. </div>
  247. </div>
  248. HTML
  249. end
  250. def generate_banner_css
  251. settings = banner_settings_with_defaults
  252. colors = settings['colors']
  253. fonts = settings['fonts']
  254. <<~CSS
  255. .consent-banner {
  256. position: fixed;
  257. bottom: 0;
  258. left: 0;
  259. right: 0;
  260. background: #{colors['background']};
  261. color: #{colors['text']};
  262. padding: 20px;
  263. box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
  264. z-index: 9999;
  265. font-family: #{fonts['family']};
  266. transform: translateY(100%);
  267. transition: transform #{settings['animation_duration']}ms ease-in-out;
  268. }
  269. .consent-banner.show {
  270. transform: translateY(0);
  271. }
  272. .consent-banner-content {
  273. max-width: 1200px;
  274. margin: 0 auto;
  275. display: flex;
  276. flex-direction: column;
  277. gap: 16px;
  278. }
  279. .consent-banner-header {
  280. display: flex;
  281. justify-content: space-between;
  282. align-items: center;
  283. }
  284. .consent-banner-title {
  285. font-size: #{fonts['size_title']};
  286. font-weight: 600;
  287. margin: 0;
  288. color: #{colors['text']};
  289. }
  290. .consent-banner-close {
  291. background: none;
  292. border: none;
  293. color: #{colors['text']};
  294. cursor: pointer;
  295. padding: 4px;
  296. border-radius: 4px;
  297. transition: background-color 0.2s;
  298. }
  299. .consent-banner-close:hover {
  300. background-color: rgba(255, 255, 255, 0.1);
  301. }
  302. .consent-banner-description {
  303. font-size: #{fonts['size_description']};
  304. margin: 0;
  305. line-height: 1.5;
  306. color: #{colors['text']};
  307. }
  308. .consent-banner-actions {
  309. display: flex;
  310. gap: 12px;
  311. flex-wrap: wrap;
  312. }
  313. .consent-btn {
  314. padding: 10px 20px;
  315. border: none;
  316. border-radius: 6px;
  317. font-size: #{fonts['size_button']};
  318. font-weight: 500;
  319. cursor: pointer;
  320. transition: all 0.2s;
  321. font-family: #{fonts['family']};
  322. }
  323. .consent-btn-primary {
  324. background-color: #{colors['button_accept']};
  325. color: white;
  326. }
  327. .consent-btn-primary:hover {
  328. opacity: 0.9;
  329. transform: translateY(-1px);
  330. }
  331. .consent-btn-secondary {
  332. background-color: #{colors['button_reject']};
  333. color: white;
  334. }
  335. .consent-btn-secondary:hover {
  336. opacity: 0.9;
  337. transform: translateY(-1px);
  338. }
  339. .consent-btn-neutral {
  340. background-color: #{colors['button_neutral']};
  341. color: white;
  342. }
  343. .consent-btn-neutral:hover {
  344. opacity: 0.9;
  345. transform: translateY(-1px);
  346. }
  347. .consent-preferences-modal {
  348. position: fixed;
  349. top: 0;
  350. left: 0;
  351. right: 0;
  352. bottom: 0;
  353. background-color: rgba(0, 0, 0, 0.5);
  354. z-index: 10000;
  355. display: flex;
  356. align-items: center;
  357. justify-content: center;
  358. padding: 20px;
  359. }
  360. .consent-modal-content {
  361. background: white;
  362. border-radius: 8px;
  363. max-width: 600px;
  364. width: 100%;
  365. max-height: 80vh;
  366. overflow-y: auto;
  367. box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
  368. }
  369. .consent-modal-header {
  370. padding: 20px;
  371. border-bottom: 1px solid #e5e7eb;
  372. display: flex;
  373. justify-content: space-between;
  374. align-items: center;
  375. }
  376. .consent-modal-title {
  377. font-size: 18px;
  378. font-weight: 600;
  379. margin: 0;
  380. color: #111827;
  381. }
  382. .consent-modal-close {
  383. background: none;
  384. border: none;
  385. color: #6b7280;
  386. cursor: pointer;
  387. padding: 4px;
  388. border-radius: 4px;
  389. transition: background-color 0.2s;
  390. }
  391. .consent-modal-close:hover {
  392. background-color: #f3f4f6;
  393. }
  394. .consent-modal-body {
  395. padding: 20px;
  396. }
  397. .consent-modal-actions {
  398. padding: 20px;
  399. border-top: 1px solid #e5e7eb;
  400. display: flex;
  401. justify-content: flex-end;
  402. gap: 12px;
  403. }
  404. .consent-category {
  405. margin-bottom: 20px;
  406. padding: 16px;
  407. border: 1px solid #e5e7eb;
  408. border-radius: 6px;
  409. }
  410. .consent-category-header {
  411. display: flex;
  412. justify-content: space-between;
  413. align-items: center;
  414. margin-bottom: 8px;
  415. }
  416. .consent-category-title {
  417. font-size: 16px;
  418. font-weight: 500;
  419. margin: 0;
  420. color: #111827;
  421. }
  422. .consent-category-description {
  423. font-size: 14px;
  424. color: #6b7280;
  425. margin: 0;
  426. line-height: 1.5;
  427. }
  428. .consent-toggle {
  429. position: relative;
  430. display: inline-block;
  431. width: 44px;
  432. height: 24px;
  433. }
  434. .consent-toggle input {
  435. opacity: 0;
  436. width: 0;
  437. height: 0;
  438. }
  439. .consent-slider {
  440. position: absolute;
  441. cursor: pointer;
  442. top: 0;
  443. left: 0;
  444. right: 0;
  445. bottom: 0;
  446. background-color: #ccc;
  447. transition: 0.4s;
  448. border-radius: 24px;
  449. }
  450. .consent-slider:before {
  451. position: absolute;
  452. content: "";
  453. height: 18px;
  454. width: 18px;
  455. left: 3px;
  456. bottom: 3px;
  457. background-color: white;
  458. transition: 0.4s;
  459. border-radius: 50%;
  460. }
  461. .consent-toggle input:checked + .consent-slider {
  462. background-color: #{colors['button_accept']};
  463. }
  464. .consent-toggle input:checked + .consent-slider:before {
  465. transform: translateX(20px);
  466. }
  467. .consent-toggle input:disabled + .consent-slider {
  468. background-color: #{colors['button_neutral']};
  469. cursor: not-allowed;
  470. }
  471. @media (max-width: 768px) {
  472. .consent-banner-actions {
  473. flex-direction: column;
  474. }
  475. .consent-btn {
  476. width: 100%;
  477. }
  478. .consent-modal-content {
  479. margin: 10px;
  480. }
  481. }
  482. #{settings['custom_css']}
  483. CSS
  484. end
  485. def generate_preferences_form(categories)
  486. form_html = ''
  487. categories.each do |category, settings|
  488. required_class = settings['required'] ? 'required' : ''
  489. disabled_attr = settings['required'] ? 'disabled' : ''
  490. checked_attr = settings['default_enabled'] ? 'checked' : ''
  491. form_html += <<~HTML
  492. <div class="consent-category #{required_class}">
  493. <div class="consent-category-header">
  494. <h4 class="consent-category-title">#{settings['name']}</h4>
  495. <label class="consent-toggle">
  496. <input type="checkbox" #{checked_attr} #{disabled_attr} data-category="#{category}">
  497. <span class="consent-slider"></span>
  498. </label>
  499. </div>
  500. <p class="consent-category-description">#{settings['description']}</p>
  501. </div>
  502. HTML
  503. end
  504. form_html
  505. end
  506. private
  507. def set_defaults
  508. self.consent_categories ||= DEFAULT_CONSENT_CATEGORIES
  509. self.banner_settings ||= DEFAULT_BANNER_SETTINGS
  510. self.geolocation_settings ||= DEFAULT_GEOLOCATION_SETTINGS
  511. self.active ||= true
  512. end
  513. def generate_banner_buttons(settings)
  514. buttons = []
  515. if settings['show_accept_all']
  516. buttons << "<button class=\"consent-btn consent-btn-primary\" onclick=\"ConsentManager.acceptAll()\">#{settings['text']['accept_all']}</button>"
  517. end
  518. if settings['show_reject_all']
  519. buttons << "<button class=\"consent-btn consent-btn-secondary\" onclick=\"ConsentManager.rejectAll()\">#{settings['text']['reject_all']}</button>"
  520. end
  521. if settings['show_necessary_only']
  522. buttons << "<button class=\"consent-btn consent-btn-neutral\" onclick=\"ConsentManager.acceptNecessary()\">#{settings['text']['necessary_only']}</button>"
  523. end
  524. if settings['show_manage_preferences']
  525. buttons << "<button class=\"consent-btn consent-btn-neutral\" onclick=\"ConsentManager.showPreferencesModal()\">#{settings['text']['manage_preferences']}</button>"
  526. end
  527. buttons.join('')
  528. end
  529. end

app/models/content_type.rb

0.0% lines covered

100.0% branches covered

59 relevant lines. 0 lines covered and 59 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ContentType < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. has_many :posts, dependent: :nullify
  6. # Validations
  7. validates :ident, presence: true, uniqueness: true, format: { with: /\A[a-z0-9_-]+\z/, message: "only allows lowercase letters, numbers, hyphens, and underscores" }
  8. validates :label, presence: true
  9. validates :singular, presence: true
  10. validates :plural, presence: true
  11. # JSON fields
  12. attribute :supports, :json, default: -> { ['title', 'editor', 'excerpt', 'thumbnail', 'comments'] }
  13. attribute :capabilities, :json, default: -> { {} }
  14. # Scopes
  15. scope :active, -> { where(active: true) }
  16. scope :public_types, -> { where(public: true) }
  17. scope :ordered, -> { order(:menu_position, :label) }
  18. # Callbacks
  19. before_validation :set_defaults, on: :create
  20. before_validation :normalize_ident
  21. # Class methods
  22. def self.find_by_ident(ident)
  23. find_by(ident: ident.to_s.downcase.strip)
  24. end
  25. def self.default_type
  26. find_by_ident('post') || first
  27. end
  28. # Instance methods
  29. def to_param
  30. ident
  31. end
  32. def display_name
  33. label
  34. end
  35. def supports?(feature)
  36. supports.is_a?(Array) && supports.include?(feature.to_s)
  37. end
  38. def add_support(feature)
  39. self.supports ||= []
  40. self.supports << feature.to_s unless supports?(feature)
  41. self.supports = supports.uniq
  42. end
  43. def remove_support(feature)
  44. self.supports ||= []
  45. self.supports.delete(feature.to_s)
  46. end
  47. def can?(capability)
  48. capabilities.is_a?(Hash) && capabilities[capability.to_s]
  49. end
  50. def rest_endpoint
  51. rest_base.presence || ident.pluralize
  52. end
  53. private
  54. def set_defaults
  55. self.rest_base ||= ident&.pluralize
  56. self.singular ||= label
  57. self.plural ||= label&.pluralize
  58. self.icon ||= 'document-text'
  59. self.public = true if public.nil?
  60. self.active = true if active.nil?
  61. self.hierarchical = false if hierarchical.nil?
  62. self.has_archive = true if has_archive.nil?
  63. end
  64. def normalize_ident
  65. self.ident = ident.to_s.downcase.strip.gsub(/[^a-z0-9_-]/, '-') if ident.present?
  66. end
  67. end

app/models/current.rb

0.0% lines covered

100.0% branches covered

3 relevant lines. 0 lines covered and 3 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Current < ActiveSupport::CurrentAttributes
  2. attribute :user
  3. end

app/models/custom_field.rb

0.0% lines covered

100.0% branches covered

99 relevant lines. 0 lines covered and 99 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CustomField < ApplicationRecord
  2. # Associations
  3. belongs_to :field_group
  4. has_many :custom_field_values, dependent: :destroy
  5. # Serialization
  6. serialize :choices, coder: JSON, type: Hash
  7. serialize :conditional_logic, coder: JSON, type: Hash
  8. serialize :settings, coder: JSON, type: Hash
  9. # Validations
  10. validates :name, presence: true
  11. validates :label, presence: true
  12. validates :field_type, presence: true, inclusion: { in: FieldGroup::FIELD_TYPES.keys }
  13. # Callbacks
  14. before_validation :normalize_name
  15. # Scopes
  16. scope :ordered, -> { order(position: :asc) }
  17. scope :required_fields, -> { where(required: true) }
  18. scope :by_type, ->(type) { where(field_type: type) }
  19. # Get formatted choices for select/radio/checkbox fields
  20. def formatted_choices
  21. return [] if choices.blank?
  22. if choices.is_a?(Hash)
  23. choices.map { |k, v| [v, k] }
  24. elsif choices.is_a?(Array)
  25. choices.map { |c| [c, c] }
  26. else
  27. []
  28. end
  29. end
  30. # Check if field should be shown based on conditional logic
  31. def should_show?(values = {})
  32. return true if conditional_logic.blank?
  33. logic = conditional_logic.is_a?(String) ? JSON.parse(conditional_logic) : conditional_logic
  34. return true if logic.blank? || logic['rules'].blank?
  35. operator = logic['operator'] || 'and' # 'and' or 'or'
  36. rules = logic['rules']
  37. results = rules.map do |rule|
  38. field_name = rule['field']
  39. condition = rule['operator'] # '==', '!=', 'contains', etc.
  40. expected_value = rule['value']
  41. actual_value = values[field_name].to_s
  42. case condition
  43. when '=='
  44. actual_value == expected_value.to_s
  45. when '!='
  46. actual_value != expected_value.to_s
  47. when 'contains'
  48. actual_value.include?(expected_value.to_s)
  49. when 'not_contains'
  50. !actual_value.include?(expected_value.to_s)
  51. when 'empty'
  52. actual_value.blank?
  53. when 'not_empty'
  54. actual_value.present?
  55. else
  56. true
  57. end
  58. end
  59. if operator == 'and'
  60. results.all?
  61. else # 'or'
  62. results.any?
  63. end
  64. rescue
  65. true # Show by default if logic is invalid
  66. end
  67. # Get setting value
  68. def get_setting(key, default = nil)
  69. return default if settings.blank?
  70. settings[key.to_s] || default
  71. end
  72. # Field type helpers
  73. def text_field?
  74. %w[text email url password].include?(field_type)
  75. end
  76. def textarea_field?
  77. field_type == 'textarea'
  78. end
  79. def number_field?
  80. field_type == 'number'
  81. end
  82. def wysiwyg_field?
  83. field_type == 'wysiwyg'
  84. end
  85. def select_field?
  86. %w[select checkbox radio button_group].include?(field_type)
  87. end
  88. def boolean_field?
  89. field_type == 'true_false'
  90. end
  91. def date_field?
  92. %w[date_picker date_time_picker time_picker].include?(field_type)
  93. end
  94. def image_field?
  95. %w[image file gallery].include?(field_type)
  96. end
  97. def relational_field?
  98. %w[post_object page_link relationship taxonomy user].include?(field_type)
  99. end
  100. def repeater_field?
  101. %w[repeater flexible_content group].include?(field_type)
  102. end
  103. private
  104. def normalize_name
  105. return if name.blank?
  106. self.name = name.parameterize(separator: '_')
  107. end
  108. end

app/models/custom_field_value.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CustomFieldValue < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. belongs_to :custom_field
  6. belongs_to :post, optional: true
  7. belongs_to :page, optional: true
  8. # Validations
  9. validates :meta_key, presence: true
  10. validate :must_belong_to_post_or_page
  11. # Scopes
  12. scope :for_post, ->(post_id) { where(post_id: post_id) }
  13. scope :for_page, ->(page_id) { where(page_id: page_id) }
  14. scope :by_key, ->(key) { where(meta_key: key) }
  15. # Get typed value based on field type
  16. def typed_value
  17. return nil if value.blank?
  18. case custom_field&.field_type
  19. when 'number'
  20. value.to_f
  21. when 'true_false'
  22. value.to_s == '1' || value.to_s.downcase == 'true'
  23. when 'checkbox'
  24. value.is_a?(String) ? JSON.parse(value) : value
  25. when 'repeater', 'flexible_content', 'group'
  26. value.is_a?(String) ? JSON.parse(value) : value
  27. when 'gallery'
  28. value.is_a?(String) ? JSON.parse(value) : value
  29. else
  30. value
  31. end
  32. rescue JSON::ParserError
  33. value
  34. end
  35. # Set value with automatic serialization
  36. def typed_value=(val)
  37. case custom_field&.field_type
  38. when 'checkbox', 'repeater', 'flexible_content', 'group', 'gallery'
  39. self.value = val.is_a?(String) ? val : val.to_json
  40. when 'true_false'
  41. self.value = val.to_s == '1' || val.to_s.downcase == 'true' ? '1' : '0'
  42. else
  43. self.value = val.to_s
  44. end
  45. end
  46. private
  47. def must_belong_to_post_or_page
  48. if post_id.blank? && page_id.blank?
  49. errors.add(:base, 'Must belong to either a post or a page')
  50. end
  51. if post_id.present? && page_id.present?
  52. errors.add(:base, 'Cannot belong to both a post and a page')
  53. end
  54. end
  55. end

app/models/custom_font.rb

0.0% lines covered

100.0% branches covered

112 relevant lines. 0 lines covered and 112 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class CustomFont < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Versioning
  5. has_paper_trail
  6. # Serialization
  7. serialize :weights, coder: JSON, type: Array
  8. serialize :styles, coder: JSON, type: Array
  9. # Validations
  10. validates :name, presence: true, uniqueness: { scope: :tenant_id }
  11. validates :family, presence: true
  12. validates :source, presence: true, inclusion: { in: %w[google custom adobe bunny] }
  13. validates :url, presence: true, if: -> { source == 'custom' }
  14. # Scopes
  15. scope :active, -> { where(active: true) }
  16. scope :google_fonts, -> { where(source: 'google') }
  17. scope :custom_fonts, -> { where(source: 'custom') }
  18. scope :ordered, -> { order(name: :asc) }
  19. # Font sources
  20. SOURCES = {
  21. 'google' => 'Google Fonts',
  22. 'custom' => 'Custom Font (Self-Hosted)',
  23. 'adobe' => 'Adobe Fonts',
  24. 'bunny' => 'Bunny Fonts (Privacy-Friendly)'
  25. }.freeze
  26. # Common fallback fonts
  27. FALLBACKS = {
  28. 'sans-serif' => 'Sans Serif',
  29. 'serif' => 'Serif',
  30. 'monospace' => 'Monospace',
  31. 'cursive' => 'Cursive',
  32. 'fantasy' => 'Fantasy'
  33. }.freeze
  34. # Font weights
  35. WEIGHTS = {
  36. '100' => 'Thin',
  37. '200' => 'Extra Light',
  38. '300' => 'Light',
  39. '400' => 'Regular',
  40. '500' => 'Medium',
  41. '600' => 'Semi Bold',
  42. '700' => 'Bold',
  43. '800' => 'Extra Bold',
  44. '900' => 'Black'
  45. }.freeze
  46. # Font styles
  47. STYLES = {
  48. 'normal' => 'Normal',
  49. 'italic' => 'Italic'
  50. }.freeze
  51. # Generate CSS @font-face rule for custom fonts
  52. def to_css
  53. return '' unless source == 'custom' && url.present?
  54. css = "@font-face {\n"
  55. css += " font-family: '#{family}';\n"
  56. css += " src: url('#{url}');\n"
  57. css += " font-display: swap;\n"
  58. # Add weight and style if specified
  59. if weights.present? && weights.first
  60. css += " font-weight: #{weights.first};\n"
  61. end
  62. if styles.present? && styles.first
  63. css += " font-style: #{styles.first};\n"
  64. end
  65. css += "}\n"
  66. css
  67. end
  68. # Generate Google Fonts URL
  69. def google_fonts_url
  70. return '' unless source == 'google'
  71. # Build Google Fonts API URL
  72. base_url = "https://fonts.googleapis.com/css2?"
  73. # Family with weights
  74. family_param = "family=#{family.gsub(' ', '+')}"
  75. if weights.present? && weights.any?
  76. weights_str = weights.map { |w| "#{w}" }.join(';')
  77. if styles.present? && styles.include?('italic')
  78. # Include italic variants
  79. weights_str = weights.map { |w| "0,#{w};1,#{w}" }.join(';')
  80. family_param += ":ital,wght@#{weights_str}"
  81. else
  82. family_param += ":wght@#{weights.join(';')}"
  83. end
  84. end
  85. "#{base_url}#{family_param}&display=swap"
  86. end
  87. # Generate Bunny Fonts URL (privacy-friendly Google Fonts alternative)
  88. def bunny_fonts_url
  89. return '' unless source == 'bunny'
  90. # Bunny Fonts uses same API as Google Fonts
  91. google_fonts_url.gsub('fonts.googleapis.com', 'fonts.bunny.net')
  92. .gsub('fonts.gstatic.com', 'fonts.bunny.net')
  93. end
  94. # Get the appropriate URL based on source
  95. def font_url
  96. case source
  97. when 'google'
  98. google_fonts_url
  99. when 'bunny'
  100. bunny_fonts_url
  101. when 'adobe'
  102. url # Adobe Fonts provides direct URL
  103. when 'custom'
  104. url
  105. else
  106. ''
  107. end
  108. end
  109. # Generate CSS link tag
  110. def to_link_tag
  111. return to_css if source == 'custom'
  112. url = font_url
  113. return '' if url.blank?
  114. "<link rel=\"preconnect\" href=\"#{preconnect_url}\">\n" \
  115. "<link href=\"#{url}\" rel=\"stylesheet\">"
  116. end
  117. # Font stack for CSS (includes fallbacks)
  118. def font_stack
  119. "'#{family}', #{fallback}"
  120. end
  121. private
  122. def preconnect_url
  123. case source
  124. when 'google'
  125. 'https://fonts.googleapis.com'
  126. when 'bunny'
  127. 'https://fonts.bunny.net'
  128. else
  129. ''
  130. end
  131. end
  132. end

app/models/email_log.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class EmailLog < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Serialize metadata as JSON
  5. serialize :metadata, coder: JSON, type: Hash
  6. # Enums
  7. enum status: {
  8. pending: 'pending',
  9. sent: 'sent',
  10. failed: 'failed',
  11. bounced: 'bounced'
  12. }, _prefix: true
  13. enum provider: {
  14. smtp: 'smtp',
  15. resend: 'resend',
  16. test: 'test'
  17. }, _prefix: true
  18. # Validations
  19. validates :from_address, :to_address, :subject, presence: true
  20. validates :status, presence: true
  21. # Scopes
  22. scope :recent, -> { order(created_at: :desc) }
  23. scope :today, -> { where('created_at >= ?', Time.current.beginning_of_day) }
  24. scope :this_week, -> { where('created_at >= ?', Time.current.beginning_of_week) }
  25. scope :this_month, -> { where('created_at >= ?', Time.current.beginning_of_month) }
  26. # Class methods
  27. def self.log_email(from:, to:, subject:, body:, provider:, status: 'pending', error: nil, metadata: {})
  28. create!(
  29. from_address: from,
  30. to_address: to,
  31. subject: subject,
  32. body: body,
  33. provider: provider,
  34. status: status,
  35. error_message: error,
  36. metadata: metadata,
  37. sent_at: status == 'sent' ? Time.current : nil
  38. )
  39. end
  40. def self.stats
  41. {
  42. total: count,
  43. sent: status_sent.count,
  44. failed: status_failed.count,
  45. pending: status_pending.count,
  46. today: today.count,
  47. this_week: this_week.count,
  48. this_month: this_month.count
  49. }
  50. end
  51. # Instance methods
  52. def success?
  53. status_sent?
  54. end
  55. def failed?
  56. status_failed? || status_bounced?
  57. end
  58. def truncated_body(length = 200)
  59. return '' if body.blank?
  60. body.truncate(length)
  61. end
  62. end

app/models/export_job.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ExportJob < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. belongs_to :user
  4. validates :export_type, presence: true
  5. validates :status, presence: true
  6. enum status: {
  7. pending: 'pending',
  8. processing: 'processing',
  9. completed: 'completed',
  10. failed: 'failed'
  11. }, _suffix: true
  12. scope :recent, -> { order(created_at: :desc) }
  13. scope :active, -> { where(status: ['pending', 'processing']) }
  14. end

app/models/field_group.rb

0.0% lines covered

100.0% branches covered

125 relevant lines. 0 lines covered and 125 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class FieldGroup < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Versioning
  5. has_paper_trail
  6. # Associations
  7. has_many :custom_fields, dependent: :destroy
  8. accepts_nested_attributes_for :custom_fields, allow_destroy: true
  9. # Serialization
  10. serialize :location_rules, coder: JSON, type: Hash
  11. # Validations
  12. validates :name, presence: true
  13. validates :slug, presence: true, uniqueness: { scope: :tenant_id }
  14. # Callbacks
  15. before_validation :generate_slug, if: -> { slug.blank? }
  16. # Scopes
  17. scope :active, -> { where(active: true) }
  18. scope :ordered, -> { order(position: :asc) }
  19. scope :for_posts, -> { where("location_rules LIKE '%post%'") }
  20. scope :for_pages, -> { where("location_rules LIKE '%page%'") }
  21. # ACF-style field types
  22. FIELD_TYPES = {
  23. # Basic
  24. 'text' => 'Text',
  25. 'textarea' => 'Text Area',
  26. 'number' => 'Number',
  27. 'email' => 'Email',
  28. 'url' => 'URL',
  29. 'password' => 'Password',
  30. # Content
  31. 'wysiwyg' => 'WYSIWYG Editor',
  32. 'oembed' => 'oEmbed',
  33. 'image' => 'Image',
  34. 'file' => 'File',
  35. 'gallery' => 'Gallery',
  36. # Choice
  37. 'select' => 'Select',
  38. 'checkbox' => 'Checkbox',
  39. 'radio' => 'Radio Button',
  40. 'button_group' => 'Button Group',
  41. 'true_false' => 'True / False',
  42. # Relational
  43. 'link' => 'Link',
  44. 'post_object' => 'Post Object',
  45. 'page_link' => 'Page Link',
  46. 'relationship' => 'Relationship',
  47. 'taxonomy' => 'Taxonomy',
  48. 'user' => 'User',
  49. # jQuery
  50. 'date_picker' => 'Date Picker',
  51. 'date_time_picker' => 'Date Time Picker',
  52. 'time_picker' => 'Time Picker',
  53. 'color_picker' => 'Color Picker',
  54. # Layout
  55. 'message' => 'Message',
  56. 'accordion' => 'Accordion',
  57. 'tab' => 'Tab',
  58. 'group' => 'Group',
  59. 'repeater' => 'Repeater',
  60. 'flexible_content' => 'Flexible Content'
  61. }.freeze
  62. # Location rules for where to show this field group
  63. def self.location_rule_operators
  64. {
  65. '==' => 'is equal to',
  66. '!=' => 'is not equal to',
  67. 'contains' => 'contains',
  68. 'not_contains' => 'does not contain'
  69. }
  70. end
  71. def self.location_rule_params
  72. {
  73. 'post_type' => 'Post Type',
  74. 'post_category' => 'Post Category',
  75. 'post_status' => 'Post Status',
  76. 'page_type' => 'Page Type',
  77. 'page_parent' => 'Page Parent',
  78. 'page_template' => 'Page Template',
  79. 'current_user_role' => 'Current User Role'
  80. }
  81. end
  82. # Check if this field group should be shown for a given object
  83. def matches_location?(object)
  84. return true if location_rules.blank?
  85. rules = location_rules.is_a?(String) ? JSON.parse(location_rules) : location_rules
  86. return true if rules.blank?
  87. # All rules must match (AND logic)
  88. rules.all? do |rule|
  89. param = rule['param']
  90. operator = rule['operator']
  91. value = rule['value']
  92. check_location_rule(object, param, operator, value)
  93. end
  94. rescue
  95. true # Show by default if rules are invalid
  96. end
  97. private
  98. def generate_slug
  99. self.slug = name.parameterize
  100. end
  101. def check_location_rule(object, param, operator, value)
  102. case param
  103. when 'post_type'
  104. return false unless object.is_a?(Post)
  105. compare_values(
  106. 'post', operator, value)
  107. when 'post_category'
  108. return false unless object.is_a?(Post)
  109. category_taxonomy = Taxonomy.find_by(slug: 'category')
  110. return false unless category_taxonomy
  111. categories = object.terms.where(taxonomy: category_taxonomy).pluck(:id).map(&:to_s)
  112. compare_values(categories, operator, value)
  113. when 'page_type'
  114. return false unless object.is_a?(Page)
  115. compare_values('page', operator, value)
  116. else
  117. true
  118. end
  119. end
  120. def compare_values(actual, operator, expected)
  121. case operator
  122. when '=='
  123. if actual.is_a?(Array)
  124. actual.include?(expected)
  125. else
  126. actual.to_s == expected.to_s
  127. end
  128. when '!='
  129. if actual.is_a?(Array)
  130. !actual.include?(expected)
  131. else
  132. actual.to_s != expected.to_s
  133. end
  134. when 'contains'
  135. actual.to_s.include?(expected.to_s)
  136. when 'not_contains'
  137. !actual.to_s.include?(expected.to_s)
  138. else
  139. true
  140. end
  141. end
  142. end

app/models/image_optimization_log.rb

0.0% lines covered

100.0% branches covered

231 relevant lines. 0 lines covered and 231 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ImageOptimizationLog < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. belongs_to :medium
  6. belongs_to :upload
  7. belongs_to :user
  8. # Serialization
  9. serialize :variants_generated, coder: JSON, type: Array
  10. serialize :responsive_variants_generated, coder: JSON, type: Array
  11. serialize :warnings, coder: JSON, type: Array
  12. # Validations
  13. validates :compression_level, presence: true
  14. validates :status, presence: true, inclusion: { in: %w[success failed skipped partial] }
  15. validates :optimization_type, presence: true, inclusion: { in: %w[upload bulk manual regenerate] }
  16. validates :original_size, presence: true, numericality: { greater_than: 0 }
  17. validates :optimized_size, presence: true, numericality: { greater_than: 0 }
  18. validates :quality, presence: true, numericality: { in: 1..100 }
  19. validates :processing_time, presence: true, numericality: { greater_than: 0 }
  20. # Scopes
  21. scope :successful, -> { where(status: 'success') }
  22. scope :failed, -> { where(status: 'failed') }
  23. scope :skipped, -> { where(status: 'skipped') }
  24. scope :partial, -> { where(status: 'partial') }
  25. scope :by_compression_level, ->(level) { where(compression_level: level) }
  26. scope :by_optimization_type, ->(type) { where(optimization_type: type) }
  27. scope :by_user, ->(user) { where(user: user) }
  28. scope :by_tenant, ->(tenant) { where(tenant: tenant) }
  29. scope :recent, -> { order(created_at: :desc) }
  30. scope :today, -> { where(created_at: Date.current.all_day) }
  31. scope :this_week, -> { where(created_at: Date.current.beginning_of_week..Date.current.end_of_week) }
  32. scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
  33. # Callbacks
  34. before_validation :calculate_metrics
  35. # Class methods for analytics
  36. def self.total_images_optimized
  37. successful.count
  38. end
  39. def self.total_bytes_saved
  40. successful.sum(:bytes_saved)
  41. end
  42. def self.total_processing_time
  43. successful.sum(:processing_time)
  44. end
  45. def self.average_size_reduction
  46. successful.average(:size_reduction_percentage)
  47. end
  48. def self.average_processing_time
  49. successful.average(:processing_time)
  50. end
  51. def self.compression_level_stats
  52. successful.group(:compression_level).count
  53. end
  54. def self.optimization_type_stats
  55. successful.group(:optimization_type).count
  56. end
  57. def self.daily_stats(days = 30)
  58. successful.where(created_at: days.days.ago..Time.current)
  59. .group("DATE(created_at)")
  60. .count
  61. end
  62. def self.user_stats
  63. successful.group(:user_id).count
  64. end
  65. def self.tenant_stats
  66. successful.group(:tenant_id).count
  67. end
  68. def self.top_savings(limit = 10)
  69. successful.order(bytes_saved: :desc).limit(limit)
  70. end
  71. def self.failed_optimizations
  72. failed.includes(:medium, :upload, :user)
  73. end
  74. # Instance methods
  75. def success?
  76. status == 'success'
  77. end
  78. def failed?
  79. status == 'failed'
  80. end
  81. def skipped?
  82. status == 'skipped'
  83. end
  84. def partial?
  85. status == 'partial'
  86. end
  87. def size_reduction_mb
  88. (bytes_saved / 1024.0 / 1024.0).round(2)
  89. end
  90. def original_size_mb
  91. (original_size / 1024.0 / 1024.0).round(2)
  92. end
  93. def optimized_size_mb
  94. (optimized_size / 1024.0 / 1024.0).round(2)
  95. end
  96. def processing_time_formatted
  97. if processing_time < 1
  98. "#{(processing_time * 1000).round(0)}ms"
  99. else
  100. "#{processing_time.round(2)}s"
  101. end
  102. end
  103. def compression_level_name
  104. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:name) || compression_level.capitalize
  105. end
  106. def compression_level_description
  107. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:description) || 'Custom settings'
  108. end
  109. def expected_savings
  110. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:expected_savings) || 'Variable'
  111. end
  112. def recommended_for
  113. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:recommended_for) || 'Advanced users'
  114. end
  115. # Status check methods
  116. def success?
  117. status == 'success'
  118. end
  119. def failed?
  120. status == 'failed'
  121. end
  122. def skipped?
  123. status == 'skipped'
  124. end
  125. def partial?
  126. status == 'partial'
  127. end
  128. # Size and time formatting methods
  129. def size_reduction_mb
  130. (bytes_saved / 1024.0 / 1024.0).round(2)
  131. end
  132. def processing_time_formatted
  133. if processing_time < 1
  134. "#{(processing_time * 1000).round(0)}ms"
  135. else
  136. "#{processing_time.round(2)}s"
  137. end
  138. end
  139. # Compression level info methods
  140. def compression_level_name
  141. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:name) || compression_level.capitalize
  142. end
  143. def compression_level_description
  144. ImageOptimizationService.available_compression_levels[compression_level]&.dig(:description) || 'Custom settings'
  145. end
  146. # API response method
  147. def api_response
  148. {
  149. id: id,
  150. filename: filename,
  151. content_type: content_type,
  152. original_size: original_size,
  153. optimized_size: optimized_size,
  154. bytes_saved: bytes_saved,
  155. size_reduction_percentage: size_reduction_percentage,
  156. size_reduction_mb: size_reduction_mb,
  157. compression_level: compression_level,
  158. compression_level_name: compression_level_name,
  159. quality: quality,
  160. processing_time: processing_time,
  161. processing_time_formatted: processing_time_formatted,
  162. status: status,
  163. optimization_type: optimization_type,
  164. variants_generated: variants_generated,
  165. responsive_variants_generated: responsive_variants_generated,
  166. error_message: error_message,
  167. warnings: warnings,
  168. user: {
  169. id: user_id,
  170. email: user&.email
  171. },
  172. medium: {
  173. id: medium_id,
  174. title: medium&.title
  175. },
  176. upload: {
  177. id: upload_id,
  178. title: upload&.title
  179. },
  180. created_at: created_at,
  181. updated_at: updated_at
  182. }
  183. end
  184. # Analytics methods
  185. def self.generate_report(start_date = 30.days.ago, end_date = Time.current)
  186. logs = where(created_at: start_date..end_date)
  187. {
  188. total_optimizations: logs.count,
  189. successful_optimizations: logs.successful.count,
  190. failed_optimizations: logs.failed.count,
  191. skipped_optimizations: logs.skipped.count,
  192. total_bytes_saved: logs.successful.sum(:bytes_saved),
  193. total_size_saved_mb: (logs.successful.sum(:bytes_saved) / 1024.0 / 1024.0).round(2),
  194. average_size_reduction: logs.successful.average(:size_reduction_percentage)&.round(2),
  195. average_processing_time: logs.successful.average(:processing_time)&.round(3),
  196. compression_level_breakdown: logs.successful.group(:compression_level).count,
  197. optimization_type_breakdown: logs.successful.group(:optimization_type).count,
  198. daily_optimizations: logs.successful.group("DATE(created_at)").count,
  199. top_users: logs.successful.group(:user_id).count.sort_by { |_, count| -count }.first(10),
  200. top_tenants: logs.successful.group(:tenant_id).count.sort_by { |_, count| -count }.first(10)
  201. }
  202. end
  203. def self.export_to_csv(start_date = 30.days.ago, end_date = Time.current)
  204. require 'csv'
  205. logs = where(created_at: start_date..end_date).includes(:medium, :upload, :user, :tenant)
  206. CSV.generate do |csv|
  207. csv << [
  208. 'Date', 'User', 'Tenant', 'Filename', 'Content Type', 'Original Size (MB)',
  209. 'Optimized Size (MB)', 'Bytes Saved', 'Size Reduction %', 'Compression Level',
  210. 'Quality', 'Processing Time', 'Status', 'Optimization Type', 'Variants Generated',
  211. 'Responsive Variants', 'Storage Provider', 'CDN Enabled', 'Error Message'
  212. ]
  213. logs.each do |log|
  214. csv << [
  215. log.created_at.strftime('%Y-%m-%d %H:%M:%S'),
  216. log.user&.email || 'Unknown',
  217. log.tenant&.name || 'Unknown',
  218. log.filename,
  219. log.content_type,
  220. log.original_size_mb,
  221. log.optimized_size_mb,
  222. log.bytes_saved,
  223. log.size_reduction_percentage,
  224. log.compression_level,
  225. log.quality,
  226. log.processing_time_formatted,
  227. log.status,
  228. log.optimization_type,
  229. log.variants_generated&.join(', ') || '',
  230. log.responsive_variants_generated&.join(', ') || '',
  231. log.storage_provider,
  232. log.cdn_enabled ? 'Yes' : 'No',
  233. log.error_message || ''
  234. ]
  235. end
  236. end
  237. end
  238. private
  239. def calculate_metrics
  240. return unless original_size && optimized_size
  241. self.bytes_saved = original_size - optimized_size
  242. self.size_reduction_percentage = ((bytes_saved.to_f / original_size) * 100).round(2)
  243. end
  244. end

app/models/import_job.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ImportJob < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. belongs_to :user
  4. validates :import_type, presence: true
  5. validates :file_path, presence: true
  6. validates :status, presence: true
  7. enum status: {
  8. pending: 'pending',
  9. processing: 'processing',
  10. completed: 'completed',
  11. failed: 'failed'
  12. }, _suffix: true
  13. scope :recent, -> { order(created_at: :desc) }
  14. scope :active, -> { where(status: ['pending', 'processing']) }
  15. end

app/models/medium.rb

0.0% lines covered

100.0% branches covered

111 relevant lines. 0 lines covered and 111 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Medium < ApplicationRecord
  2. include Railspress::ChannelDetection
  3. # Multi-tenancy
  4. acts_as_tenant(:tenant)
  5. # Trash functionality
  6. include Trashable
  7. belongs_to :user
  8. belongs_to :upload
  9. # Channels
  10. has_and_belongs_to_many :channels
  11. # Validations
  12. validates :title, presence: true
  13. # Callbacks
  14. after_create :trigger_media_uploaded_hook
  15. # Scopes
  16. scope :images, -> { joins(:upload).merge(Upload.images) }
  17. scope :videos, -> { joins(:upload).merge(Upload.videos) }
  18. scope :documents, -> { joins(:upload).merge(Upload.documents) }
  19. scope :recent, -> { order(created_at: :desc) }
  20. scope :approved, -> { joins(:upload).merge(Upload.approved) }
  21. scope :quarantined, -> { joins(:upload).merge(Upload.quarantined) }
  22. # File methods - delegate to upload
  23. def image?
  24. upload&.image?
  25. end
  26. def video?
  27. upload&.video?
  28. end
  29. def document?
  30. upload&.document?
  31. end
  32. def file_size
  33. upload&.file_size || 0
  34. end
  35. def content_type
  36. upload&.content_type
  37. end
  38. def filename
  39. upload&.filename
  40. end
  41. def url
  42. upload&.url
  43. end
  44. def file_attached?
  45. upload&.file&.attached?
  46. end
  47. def quarantined?
  48. upload&.quarantined?
  49. end
  50. def approved?
  51. upload&.approved?
  52. end
  53. def quarantine_reason
  54. upload&.quarantine_reason
  55. end
  56. # API serialization helpers
  57. def api_attributes
  58. {
  59. id: id,
  60. title: title,
  61. description: description,
  62. alt_text: alt_text,
  63. filename: filename,
  64. content_type: content_type,
  65. file_size: file_size,
  66. url: url,
  67. image: image?,
  68. video: video?,
  69. document: document?,
  70. quarantined: quarantined?,
  71. quarantine_reason: quarantine_reason,
  72. created_at: created_at,
  73. updated_at: updated_at,
  74. user: {
  75. id: user.id,
  76. name: user.name,
  77. email: user.email
  78. },
  79. upload: {
  80. id: upload.id,
  81. title: upload.title,
  82. storage_provider: {
  83. id: upload.storage_provider.id,
  84. name: upload.storage_provider.name,
  85. type: upload.storage_provider.provider_type
  86. }
  87. }
  88. }
  89. end
  90. # Class methods for API
  91. def self.with_file_info
  92. includes(:upload, :user, upload: :storage_provider)
  93. end
  94. def self.by_type(type)
  95. case type.to_s
  96. when 'image'
  97. images
  98. when 'video'
  99. videos
  100. when 'document'
  101. documents
  102. else
  103. all
  104. end
  105. end
  106. def trigger_media_uploaded_hook
  107. # Trigger plugin hooks
  108. Railspress::PluginSystem.do_action('media_uploaded', self)
  109. # Core image optimization (baked into system)
  110. optimize_image_if_needed
  111. end
  112. # Core image optimization method
  113. def optimize_image_if_needed
  114. return unless image?
  115. return unless upload&.file&.attached?
  116. # Check if optimization is enabled in settings
  117. storage_config = StorageConfigurationService.new
  118. return unless storage_config.auto_optimize_enabled?
  119. # Check media settings
  120. return unless SiteSetting.get('auto_optimize_images', false)
  121. # Queue optimization job
  122. OptimizeImageJob.perform_later(medium_id: id)
  123. Rails.logger.info "Queued image optimization for medium #{id} (core system)"
  124. end
  125. private
  126. end

app/models/menu.rb

0.0% lines covered

100.0% branches covered

10 relevant lines. 0 lines covered and 10 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Menu < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. has_many :menu_items, dependent: :destroy
  6. # Validations
  7. validates :name, presence: true, uniqueness: true
  8. validates :location, presence: true
  9. # Scopes
  10. scope :by_location, ->(location) { where(location: location) }
  11. # Methods
  12. def root_items
  13. menu_items.where(parent_id: nil).order(position: :asc)
  14. end
  15. end

app/models/menu_item.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class MenuItem < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. belongs_to :menu
  5. belongs_to :parent, class_name: 'MenuItem', optional: true
  6. # Hierarchical structure
  7. has_many :children, class_name: 'MenuItem', foreign_key: 'parent_id', dependent: :destroy
  8. # Validations
  9. validates :label, presence: true
  10. validates :url, presence: true
  11. validates :position, presence: true, numericality: { only_integer: true }
  12. # Scopes
  13. scope :ordered, -> { order(position: :asc) }
  14. scope :root_items, -> { where(parent_id: nil) }
  15. # Callbacks
  16. before_validation :set_position, on: :create
  17. private
  18. def set_position
  19. return unless menu.present?
  20. self.position ||= (menu.menu_items.maximum(:position) || 0) + 1
  21. end
  22. end

app/models/meta_field.rb

0.0% lines covered

100.0% branches covered

97 relevant lines. 0 lines covered and 97 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class MetaField < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant, optional: true)
  4. belongs_to :metable, polymorphic: true
  5. # Validations
  6. validates :key, presence: true, length: { maximum: 255 }
  7. validates :key, uniqueness: { scope: [:metable_type, :metable_id], message: "must be unique per metable" }
  8. validates :immutable, inclusion: { in: [true, false] }
  9. # Scopes
  10. scope :immutable, -> { where(immutable: true) }
  11. scope :mutable, -> { where(immutable: false) }
  12. scope :by_key, ->(key) { where(key: key) }
  13. # Callbacks for cache invalidation
  14. after_save :invalidate_metable_cache
  15. after_destroy :invalidate_metable_cache
  16. # Class methods for easy access
  17. def self.get(metable, key)
  18. cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
  19. Rails.cache.fetch(cache_key, expires_in: 1.hour) do
  20. find_by(metable: metable, key: key)&.value
  21. end
  22. end
  23. def self.set(metable, key, value, immutable: false)
  24. meta_field = find_or_initialize_by(metable: metable, key: key)
  25. if meta_field.persisted? && meta_field.immutable?
  26. raise ArgumentError, "Cannot modify immutable meta field: #{key}"
  27. end
  28. meta_field.assign_attributes(value: value, immutable: immutable)
  29. meta_field.save!
  30. # Update cache
  31. cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
  32. Rails.cache.write(cache_key, value, expires_in: 1.hour)
  33. meta_field
  34. end
  35. def self.delete(metable, key)
  36. meta_field = find_by(metable: metable, key: key)
  37. if meta_field&.immutable?
  38. raise ArgumentError, "Cannot delete immutable meta field: #{key}"
  39. end
  40. if meta_field&.destroy
  41. # Clear cache
  42. cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
  43. Rails.cache.delete(cache_key)
  44. # Clear metable's meta cache
  45. metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
  46. Rails.cache.delete(metable_cache_key)
  47. end
  48. meta_field
  49. end
  50. def self.bulk_get(metable, keys)
  51. cache_keys = keys.map { |key| "meta_field:#{metable.class.name}:#{metable.id}:#{key}" }
  52. cached_values = Rails.cache.read_multi(*cache_keys)
  53. missing_keys = keys - cached_values.keys.map { |k| k.split(':').last }
  54. if missing_keys.any?
  55. # Fetch missing values from database
  56. missing_meta_fields = where(metable: metable, key: missing_keys)
  57. missing_meta_fields.each do |meta_field|
  58. cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{meta_field.key}"
  59. Rails.cache.write(cache_key, meta_field.value, expires_in: 1.hour)
  60. cached_values[meta_field.key] = meta_field.value
  61. end
  62. end
  63. # Return values in the same order as requested keys
  64. keys.map { |key| cached_values[key] }
  65. end
  66. def self.bulk_set(metable, hash, immutable: false)
  67. transaction do
  68. hash.each do |key, value|
  69. set(metable, key, value, immutable: immutable)
  70. end
  71. end
  72. # Clear metable's meta cache
  73. metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
  74. Rails.cache.delete(metable_cache_key)
  75. end
  76. def self.all_for(metable)
  77. cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
  78. Rails.cache.fetch(cache_key, expires_in: 1.hour) do
  79. where(metable: metable).pluck(:key, :value, :immutable).to_h do |key, value, immutable|
  80. [key, { value: value, immutable: immutable }]
  81. end
  82. end
  83. end
  84. # Instance methods
  85. def to_s
  86. value.to_s
  87. end
  88. def to_i
  89. value.to_i
  90. end
  91. def to_f
  92. value.to_f
  93. end
  94. def to_bool
  95. ActiveModel::Type::Boolean.new.cast(value)
  96. end
  97. def json_value
  98. JSON.parse(value) if value.present?
  99. rescue JSON::ParserError
  100. nil
  101. end
  102. private
  103. def invalidate_metable_cache
  104. # Clear individual field cache
  105. cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
  106. Rails.cache.delete(cache_key)
  107. # Clear metable's meta cache
  108. metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
  109. Rails.cache.delete(metable_cache_key)
  110. end
  111. end

app/models/oauth_account.rb

0.0% lines covered

100.0% branches covered

78 relevant lines. 0 lines covered and 78 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class OauthAccount < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. belongs_to :user
  6. # Validations
  7. validates :provider, presence: true
  8. validates :uid, presence: true
  9. validates :email, presence: true
  10. validates :name, presence: true
  11. validates :uid, uniqueness: { scope: [:provider, :tenant_id] }
  12. # Scopes
  13. scope :by_provider, ->(provider) { where(provider: provider) }
  14. scope :active, -> { joins(:user).where(users: { active: true }) }
  15. # Class methods
  16. def self.find_by_provider_and_uid(provider, uid)
  17. find_by(provider: provider, uid: uid)
  18. end
  19. def self.find_by_provider_and_email(provider, email)
  20. find_by(provider: provider, email: email)
  21. end
  22. # Instance methods
  23. def provider_display_name
  24. case provider
  25. when 'google_oauth2'
  26. 'Google'
  27. when 'github'
  28. 'GitHub'
  29. when 'facebook'
  30. 'Facebook'
  31. when 'twitter'
  32. 'Twitter'
  33. else
  34. provider.humanize
  35. end
  36. end
  37. def provider_icon
  38. case provider
  39. when 'google_oauth2'
  40. 'https://developers.google.com/identity/images/g-logo.png'
  41. when 'github'
  42. 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
  43. when 'facebook'
  44. 'https://facebookbrand.com/wp-content/uploads/2019/04/f_logo_RGB-Hex-Blue_512.png'
  45. when 'twitter'
  46. 'https://abs.twimg.com/icons/apple-touch-icon-192x192.png'
  47. else
  48. nil
  49. end
  50. end
  51. def provider_color
  52. case provider
  53. when 'google_oauth2'
  54. '#4285F4'
  55. when 'github'
  56. '#333333'
  57. when 'facebook'
  58. '#1877F2'
  59. when 'twitter'
  60. '#1DA1F2'
  61. else
  62. '#6B7280'
  63. end
  64. end
  65. def linked_at
  66. created_at
  67. end
  68. def last_used_at
  69. updated_at
  70. end
  71. # Check if this OAuth account is still valid
  72. def valid_oauth_account?
  73. user.present? && user.active?
  74. end
  75. # Unlink this OAuth account
  76. def unlink!
  77. destroy!
  78. end
  79. # Update OAuth account information
  80. def update_oauth_info(email: nil, name: nil, avatar_url: nil)
  81. update!(
  82. email: email || self.email,
  83. name: name || self.name,
  84. avatar_url: avatar_url || self.avatar_url
  85. )
  86. end
  87. end

app/models/page.rb

0.0% lines covered

100.0% branches covered

155 relevant lines. 0 lines covered and 155 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Page < ApplicationRecord
  2. include Railspress::ChannelDetection
  3. # Multi-tenancy
  4. acts_as_tenant(:tenant, optional: true)
  5. # Trash functionality
  6. include Trashable
  7. # Soft deletes
  8. include Discard::Model
  9. self.discard_column = :deleted_at
  10. # Versioning
  11. has_paper_trail
  12. # Search - Database agnostic
  13. def self.search_full_text(query)
  14. return none if query.blank?
  15. # Simple LIKE search that works with all databases
  16. query_pattern = "%#{query}%"
  17. where(
  18. "title LIKE ? OR meta_description LIKE ? OR content LIKE ?",
  19. query_pattern, query_pattern, query_pattern
  20. )
  21. end
  22. # Custom Taxonomies
  23. include HasTaxonomies
  24. # Meta fields for plugin extensibility
  25. has_many :meta_fields, as: :metable, dependent: :destroy
  26. include Metable
  27. # SEO
  28. include SeoOptimizable
  29. belongs_to :user
  30. belongs_to :parent, class_name: 'Page', optional: true
  31. belongs_to :page_template, optional: true
  32. # Rich text content
  33. has_rich_text :content
  34. # Channels
  35. has_and_belongs_to_many :channels
  36. # Hierarchical structure
  37. has_many :children, class_name: 'Page', foreign_key: 'parent_id', dependent: :destroy
  38. # Comments
  39. has_many :comments, as: :commentable, dependent: :destroy
  40. # Status enum
  41. enum status: {
  42. draft: 0,
  43. published: 1,
  44. scheduled: 2,
  45. pending_review: 3,
  46. private_page: 4,
  47. trash: 5
  48. }, _suffix: true
  49. # Status scopes
  50. scope :visible_to_public, -> {
  51. kept.where(status: [:published, :scheduled])
  52. .where('published_at IS NULL OR published_at <= ?', Time.current)
  53. }
  54. scope :not_trashed, -> { where.not(status: :trash) }
  55. scope :trashed, -> { where(status: :trash) }
  56. scope :awaiting_review, -> { where(status: :pending_review) }
  57. scope :scheduled_future, -> {
  58. where(status: :scheduled)
  59. .where('published_at > ?', Time.current)
  60. }
  61. scope :scheduled_past, -> {
  62. where(status: :scheduled)
  63. .where('published_at <= ?', Time.current)
  64. }
  65. # Check if page should be visible to public (without password check)
  66. def visible_to_public?
  67. return false if trash_status?
  68. return false if draft_status?
  69. return false if pending_review_status?
  70. return false if private_page_status? # Only for logged-in users
  71. if scheduled_status?
  72. published_at.present? && published_at <= Time.current
  73. else
  74. published_status?
  75. end
  76. end
  77. # Check if page is password protected
  78. def password_protected?
  79. password.present?
  80. end
  81. # Check if provided password is correct
  82. def password_matches?(input_password)
  83. return true unless password_protected?
  84. password == input_password
  85. end
  86. # Auto-publish scheduled pages
  87. def check_scheduled_publish
  88. if scheduled_status? && published_at.present? && published_at <= Time.current
  89. update(status: :published)
  90. end
  91. end
  92. # Friendly ID for slugs
  93. extend FriendlyId
  94. friendly_id :title, use: :slugged
  95. # Validations
  96. validates :title, presence: true
  97. validates :slug, presence: true, uniqueness: true
  98. validates :status, presence: true
  99. validates :password, length: { minimum: 4 }, allow_blank: true
  100. # Scopes
  101. scope :published, -> { where(status: 'published').where('published_at <= ?', Time.current) }
  102. scope :root_pages, -> { where(parent_id: nil) }
  103. scope :ordered, -> { order(order: :asc, title: :asc) }
  104. # Callbacks
  105. before_validation :set_published_at, if: :published_status?
  106. after_create :trigger_page_created_hook
  107. after_update :trigger_page_updated_hook, if: :saved_change_to_status?
  108. # Methods
  109. def should_generate_new_friendly_id?
  110. title_changed? || slug.blank?
  111. end
  112. def breadcrumbs
  113. result = [self]
  114. current = self
  115. while current.parent.present?
  116. result.unshift(current.parent)
  117. current = current.parent
  118. end
  119. result
  120. end
  121. private
  122. def set_published_at
  123. self.published_at ||= Time.current
  124. end
  125. def trigger_page_created_hook
  126. Railspress::PluginSystem.do_action('page_created', self)
  127. end
  128. def trigger_page_updated_hook
  129. if published_status?
  130. Railspress::PluginSystem.do_action('page_published', self)
  131. end
  132. Railspress::PluginSystem.do_action('page_updated', self)
  133. end
  134. # SEO URL override
  135. def seo_default_url
  136. Rails.application.routes.url_helpers.page_url(slug)
  137. rescue
  138. "#"
  139. end
  140. # Custom Fields (ACF-style)
  141. has_many :custom_field_values, dependent: :destroy
  142. # Get field value by name
  143. def get_field(field_name)
  144. value = custom_field_values.by_key(field_name.to_s).first
  145. value&.typed_value
  146. end
  147. # Set field value by name
  148. def set_field(field_name, field_value)
  149. field = CustomField.joins(:field_group)
  150. .where('custom_fields.name = ?', field_name.to_s)
  151. .where('field_groups.active = ?', true)
  152. .first
  153. return false unless field
  154. value_record = custom_field_values.find_or_initialize_by(
  155. custom_field: field,
  156. meta_key: field_name.to_s
  157. )
  158. value_record.typed_value = field_value
  159. value_record.save
  160. end
  161. # Get all fields as hash
  162. def get_fields
  163. custom_field_values.includes(:custom_field).each_with_object({}) do |cfv, hash|
  164. hash[cfv.meta_key] = cfv.typed_value
  165. end
  166. end
  167. # Update multiple fields at once
  168. def update_fields(fields_hash)
  169. fields_hash.each do |key, value|
  170. set_field(key, value)
  171. end
  172. end
  173. # Get field groups that should be shown for this page
  174. def applicable_field_groups
  175. FieldGroup.active.ordered.select { |fg| fg.matches_location?(self) }
  176. end
  177. # Template methods
  178. def template
  179. page_template || default_template
  180. end
  181. def default_template
  182. PageTemplate.active.by_type('default').first
  183. end
  184. def render_with_template
  185. template&.render_content(self) || content.to_s
  186. end
  187. end

app/models/page_template.rb

0.0% lines covered

100.0% branches covered

144 relevant lines. 0 lines covered and 144 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PageTemplate < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. has_many :pages, dependent: :nullify
  6. # Template types
  7. TEMPLATE_TYPES = %w[
  8. default
  9. full_width
  10. landing_page
  11. contact_page
  12. about_page
  13. portfolio_page
  14. blog_page
  15. custom
  16. ].freeze
  17. # Validations
  18. validates :name, presence: true
  19. validates :template_type, presence: true, inclusion: { in: TEMPLATE_TYPES }
  20. validates :template_type, uniqueness: { scope: :tenant_id }
  21. # Scopes
  22. scope :active, -> { where(active: true) }
  23. scope :by_type, ->(type) { where(template_type: type) }
  24. scope :ordered, -> { order(:position, :name) }
  25. # Callbacks
  26. after_initialize :set_defaults, if: :new_record?
  27. # Methods
  28. def render_content(page = nil)
  29. content = html_content.presence || default_template
  30. if page
  31. # Replace template variables with page data
  32. content = content.gsub('{{page.title}}', page.title || '')
  33. content = content.gsub('{{page.content}}', page.content.to_s || '')
  34. content = content.gsub('{{page.slug}}', page.slug || '')
  35. content = content.gsub('{{page.meta_description}}', page.meta_description || '')
  36. end
  37. content
  38. end
  39. def render_css
  40. css_content.presence || default_css
  41. end
  42. def render_js
  43. js_content.presence || ''
  44. end
  45. def default_template?
  46. template_type == 'default'
  47. end
  48. private
  49. def set_defaults
  50. self.active = true if active.nil?
  51. self.position ||= 0
  52. self.html_content ||= default_template
  53. self.css_content ||= default_css
  54. self.js_content ||= ''
  55. end
  56. def default_template
  57. case template_type
  58. when 'full_width'
  59. <<-HTML
  60. <div class="min-h-screen">
  61. <div class="container mx-auto px-4 py-8">
  62. <h1 class="text-4xl font-bold mb-8">{{page.title}}</h1>
  63. <div class="prose prose-lg max-w-none">
  64. {{page.content}}
  65. </div>
  66. </div>
  67. </div>
  68. HTML
  69. when 'landing_page'
  70. <<-HTML
  71. <div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
  72. <div class="container mx-auto px-4 py-16">
  73. <div class="text-center">
  74. <h1 class="text-5xl font-bold text-gray-900 mb-6">{{page.title}}</h1>
  75. <div class="prose prose-lg max-w-3xl mx-auto">
  76. {{page.content}}
  77. </div>
  78. </div>
  79. </div>
  80. </div>
  81. HTML
  82. when 'contact_page'
  83. <<-HTML
  84. <div class="container mx-auto px-4 py-8">
  85. <div class="max-w-2xl mx-auto">
  86. <h1 class="text-4xl font-bold mb-8">{{page.title}}</h1>
  87. <div class="prose prose-lg max-w-none mb-8">
  88. {{page.content}}
  89. </div>
  90. <div class="bg-white rounded-lg shadow-md p-8">
  91. <!-- Contact form placeholder -->
  92. <form class="space-y-6">
  93. <div>
  94. <label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
  95. <input type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
  96. </div>
  97. <div>
  98. <label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
  99. <input type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
  100. </div>
  101. <div>
  102. <label class="block text-sm font-medium text-gray-700 mb-2">Message</label>
  103. <textarea rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"></textarea>
  104. </div>
  105. <button type="submit" class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700">Send Message</button>
  106. </form>
  107. </div>
  108. </div>
  109. </div>
  110. HTML
  111. else
  112. <<-HTML
  113. <div class="container mx-auto px-4 py-8">
  114. <div class="max-w-4xl mx-auto">
  115. <article class="bg-white rounded-lg shadow-md p-8">
  116. <h1 class="text-4xl font-bold text-gray-900 mb-8">{{page.title}}</h1>
  117. <div class="prose prose-lg max-w-none">
  118. {{page.content}}
  119. </div>
  120. </article>
  121. </div>
  122. </div>
  123. HTML
  124. end
  125. end
  126. def default_css
  127. <<-CSS
  128. /* Page Template Styles */
  129. .page-template-#{template_type} {
  130. font-family: system-ui, -apple-system, sans-serif;
  131. line-height: 1.6;
  132. color: #333;
  133. }
  134. .page-template-#{template_type} h1,
  135. .page-template-#{template_type} h2,
  136. .page-template-#{template_type} h3 {
  137. color: #1f2937;
  138. font-weight: 700;
  139. }
  140. .page-template-#{template_type} .prose {
  141. color: #4b5563;
  142. }
  143. .page-template-#{template_type} .prose a {
  144. color: #3b82f6;
  145. text-decoration: none;
  146. }
  147. .page-template-#{template_type} .prose a:hover {
  148. text-decoration: underline;
  149. }
  150. CSS
  151. end
  152. end

app/models/pageview.rb

0.0% lines covered

100.0% branches covered

320 relevant lines. 0 lines covered and 320 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Pageview < ApplicationRecord
  2. # Multi-tenancy - make tenant optional for analytics tracking
  3. acts_as_tenant(:tenant, optional: true)
  4. # Associations
  5. belongs_to :user, optional: true
  6. belongs_to :post, optional: true
  7. belongs_to :page, optional: true
  8. # Serialization
  9. serialize :metadata, coder: JSON, type: Hash
  10. # Validations
  11. validates :path, presence: true
  12. validates :visited_at, presence: true
  13. # Scopes
  14. scope :consented_only, -> { where(consented: true) }
  15. scope :non_bot, -> { where(bot: false) }
  16. scope :unique_visitors, -> { where(unique_visitor: true) }
  17. scope :returning_visitors, -> { where(returning_visitor: true) }
  18. scope :today, -> { where('visited_at >= ?', Time.current.beginning_of_day) }
  19. scope :this_week, -> { where('visited_at >= ?', 1.week.ago) }
  20. scope :this_month, -> { where('visited_at >= ?', 1.month.ago) }
  21. scope :by_country, ->(code) { where(country_code: code) }
  22. scope :by_browser, ->(browser) { where(browser: browser) }
  23. scope :by_device, ->(device) { where(device: device) }
  24. scope :for_post, ->(post_id) { where(post_id: post_id) }
  25. scope :for_page, ->(page_id) { where(page_id: page_id) }
  26. scope :recent, -> { order(visited_at: :desc) }
  27. # Class methods for statistics
  28. # Get overview statistics
  29. def self.stats(period: :month)
  30. range = case period.to_sym
  31. when :today
  32. Time.current.beginning_of_day..Time.current.end_of_day
  33. when :week
  34. 1.week.ago..Time.current
  35. when :month
  36. 1.month.ago..Time.current
  37. when :year
  38. 1.year.ago..Time.current
  39. else
  40. 1.month.ago..Time.current
  41. end
  42. views = where(visited_at: range).non_bot
  43. consented_views = views.consented_only
  44. {
  45. total_pageviews: views.count,
  46. consented_pageviews: consented_views.count,
  47. unique_visitors: views.where(unique_visitor: true).count,
  48. returning_visitors: views.where(returning_visitor: true).count,
  49. avg_duration: views.average(:duration)&.to_i || 0,
  50. bounce_rate: calculate_bounce_rate(views),
  51. top_pages: top_pages(consented_views, 10),
  52. top_posts: top_posts(consented_views, 10),
  53. top_countries: top_countries(consented_views, 10),
  54. top_browsers: top_browsers(consented_views, 5),
  55. top_devices: top_devices(consented_views, 5),
  56. top_referrers: top_referrers(consented_views, 10),
  57. hourly_distribution: hourly_distribution(consented_views),
  58. daily_trend: daily_trend(consented_views, 30)
  59. }
  60. end
  61. # Top pages by views
  62. def self.top_pages(scope = all, limit = 10)
  63. scope.group(:path, :title)
  64. .order('count_id DESC')
  65. .limit(limit)
  66. .count(:id)
  67. .map { |k, v| { path: k[0], title: k[1], views: v } }
  68. end
  69. # Top posts by views
  70. def self.top_posts(scope = all, limit = 10)
  71. scope.where.not(post_id: nil)
  72. .group(:post_id)
  73. .order('count_id DESC')
  74. .limit(limit)
  75. .count(:id)
  76. .map do |post_id, count|
  77. post = Post.find_by(id: post_id)
  78. { post_id: post_id, title: post&.title, views: count }
  79. end
  80. end
  81. # Top countries by visitors
  82. def self.top_countries(scope = all, limit = 10)
  83. scope.where.not(country_code: nil)
  84. .group(:country_code)
  85. .order('count_id DESC')
  86. .limit(limit)
  87. .count(:id)
  88. .map { |code, count| { country_code: code, count: count } }
  89. end
  90. # Top browsers
  91. def self.top_browsers(scope = all, limit = 5)
  92. scope.where.not(browser: nil)
  93. .group(:browser)
  94. .order('count_id DESC')
  95. .limit(limit)
  96. .count(:id)
  97. end
  98. # Top devices
  99. def self.top_devices(scope = all, limit = 5)
  100. scope.where.not(device: nil)
  101. .group(:device)
  102. .order('count_id DESC')
  103. .limit(limit)
  104. .count(:id)
  105. end
  106. # Top referrers
  107. def self.top_referrers(scope = all, limit = 10)
  108. scope.where.not(referrer: [nil, ''])
  109. .group(:referrer)
  110. .order('count_id DESC')
  111. .limit(limit)
  112. .count(:id)
  113. .map { |ref, count| { referrer: ref, count: count } }
  114. end
  115. # Hourly distribution (0-23)
  116. def self.hourly_distribution(scope = all)
  117. distribution = scope.group("CAST(strftime('%H', visited_at) AS INTEGER)")
  118. .count
  119. (0..23).map { |hour| distribution[hour] || 0 }
  120. end
  121. # Daily trend (last N days)
  122. def self.daily_trend(scope = all, days = 30)
  123. scope.where('visited_at >= ?', days.days.ago)
  124. .group("DATE(visited_at)")
  125. .order("DATE(visited_at)")
  126. .count
  127. .map { |date, count| { date: date, count: count } }
  128. end
  129. # Calculate bounce rate (single-page sessions)
  130. def self.calculate_bounce_rate(scope = all)
  131. total_sessions = scope.distinct.count(:session_id)
  132. return 0 if total_sessions.zero?
  133. single_page_sessions = scope.group(:session_id)
  134. .having('COUNT(*) = 1')
  135. .count
  136. .size
  137. ((single_page_sessions.to_f / total_sessions) * 100).round(1)
  138. end
  139. # Get real-time active users (last 5 minutes)
  140. def self.active_now
  141. where('visited_at >= ?', 5.minutes.ago)
  142. .non_bot
  143. .distinct
  144. .count(:session_id)
  145. end
  146. # Track a pageview (called from middleware)
  147. def self.track(request, options = {})
  148. # Skip if bot and not tracking bots
  149. return if is_bot?(request.user_agent) && !options[:track_bots]
  150. # Parse user agent
  151. ua_data = parse_user_agent(request.user_agent)
  152. # Get or create session
  153. session_id = options[:session_id] || generate_session_id(request)
  154. # Check if unique visitor
  155. is_unique = !exists?(session_id: session_id)
  156. is_returning = exists?(ip_hash: hash_ip(request.ip)) && !is_unique
  157. # Get content IDs
  158. content_ids = extract_content_ids(request.path)
  159. # Get geolocation data
  160. geolocation_data = get_geolocation_data(request.ip)
  161. # Resolve tenant for this request
  162. tenant = resolve_tenant(request)
  163. # Enhanced metadata collection
  164. enhanced_metadata = (options[:metadata] || {}).merge({
  165. request_method: request.request_method,
  166. query_string: request.query_string,
  167. content_type: request.content_type,
  168. accept_language: request.get_header('HTTP_ACCEPT_LANGUAGE'),
  169. accept_encoding: request.get_header('HTTP_ACCEPT_ENCODING'),
  170. connection: request.get_header('HTTP_CONNECTION'),
  171. cache_control: request.get_header('HTTP_CACHE_CONTROL'),
  172. timestamp: Time.current.iso8601
  173. })
  174. # Create pageview with enhanced data
  175. create!(
  176. path: request.path,
  177. title: options[:title] || extract_title_from_path(request.path),
  178. referrer: request.referer,
  179. user_agent: request.user_agent,
  180. browser: ua_data[:browser],
  181. device: ua_data[:device],
  182. os: ua_data[:os],
  183. ip_hash: hash_ip(request.ip),
  184. session_id: session_id,
  185. user_id: options[:user_id],
  186. post_id: content_ids[:post_id],
  187. page_id: content_ids[:page_id],
  188. unique_visitor: is_unique,
  189. returning_visitor: is_returning,
  190. bot: is_bot?(request.user_agent),
  191. consented: options[:consented] || false,
  192. visited_at: Time.current,
  193. metadata: enhanced_metadata,
  194. tenant: tenant,
  195. # Geolocation data
  196. country_code: geolocation_data&.dig(:country_code),
  197. country_name: geolocation_data&.dig(:country_name),
  198. city: geolocation_data&.dig(:city),
  199. region: geolocation_data&.dig(:region),
  200. latitude: geolocation_data&.dig(:latitude),
  201. longitude: geolocation_data&.dig(:longitude),
  202. timezone: geolocation_data&.dig(:timezone),
  203. # Medium-like reader tracking
  204. is_reader: options[:is_reader] || false,
  205. engagement_score: options[:engagement_score] || 0
  206. )
  207. # Broadcast real-time update via ActionCable
  208. RealtimeAnalyticsService.broadcast_new_pageview(pageview) if pageview.persisted?
  209. pageview
  210. rescue => e
  211. Rails.logger.error "Failed to track pageview: #{e.message}"
  212. nil
  213. end
  214. # GDPR: Anonymize old data
  215. def self.anonymize_old_data(days_old = 90)
  216. where('created_at < ?', days_old.days.ago).update_all(
  217. ip_hash: nil,
  218. session_id: nil,
  219. city: nil,
  220. region: nil,
  221. metadata: {}
  222. )
  223. end
  224. # GDPR: Delete non-consented data
  225. def self.purge_non_consented(days_old = 30)
  226. where(consented: false)
  227. .where('created_at < ?', days_old.days.ago)
  228. .delete_all
  229. end
  230. private
  231. # Get geolocation data for an IP address
  232. def self.get_geolocation_data(ip_address)
  233. return nil unless SiteSetting.get('geolocation_enabled', true)
  234. begin
  235. GeolocationService.instance.lookup_ip(ip_address)
  236. rescue => e
  237. Rails.logger.error "Geolocation lookup failed for #{ip_address}: #{e.message}"
  238. nil
  239. end
  240. end
  241. # High-volume performance optimizations
  242. def self.high_volume_mode?
  243. SiteSetting.get('analytics_high_volume_mode', false)
  244. end
  245. def self.track_async(request, options)
  246. # Queue pageview for background processing
  247. AnalyticsProcessingJob.perform_later(
  248. path: request.path,
  249. referrer: request.referer,
  250. user_agent: request.user_agent,
  251. ip_hash: hash_ip(request.ip),
  252. session_id: options[:session_id] || generate_session_id,
  253. tenant_id: options[:tenant_id],
  254. visited_at: Time.current,
  255. bot: is_bot?(request.user_agent),
  256. consented: options[:consented] || false
  257. )
  258. end
  259. def self.parse_user_agent_cached(user_agent)
  260. # Simple caching for user agent parsing
  261. @ua_cache ||= {}
  262. @ua_cache[user_agent] ||= parse_user_agent(user_agent)
  263. end
  264. # Resolve tenant for the given request
  265. def self.resolve_tenant(request)
  266. # Priority 1: Find tenant by domain or subdomain
  267. if request.host != 'localhost'
  268. tenant = Tenant.find_by(domain: request.host) ||
  269. Tenant.find_by(subdomain: request.subdomains.first)
  270. return tenant if tenant
  271. end
  272. # Priority 2: Use default tenant for localhost/frontend
  273. unless request.path.start_with?('/admin')
  274. tenant = Tenant.first || Tenant.create!(
  275. name: 'RailsPress Default',
  276. domain: 'localhost',
  277. theme: 'nordic',
  278. storage_type: 'local'
  279. )
  280. return tenant
  281. end
  282. # Priority 3: Try to get from current acts_as_tenant context
  283. ActsAsTenant.current_tenant
  284. end
  285. # Hash IP address for privacy
  286. def self.hash_ip(ip)
  287. return nil unless ip
  288. Digest::SHA256.hexdigest("#{ip}-#{Rails.application.secret_key_base}")[0..15]
  289. end
  290. # Generate session ID
  291. def self.generate_session_id(request)
  292. data = "#{request.ip}-#{request.user_agent}-#{Date.today}"
  293. Digest::SHA256.hexdigest(data)[0..31]
  294. end
  295. # Check if bot
  296. def self.is_bot?(user_agent)
  297. return true if user_agent.blank?
  298. bot_patterns = [
  299. /bot/i, /crawl/i, /spider/i, /slurp/i,
  300. /googlebot/i, /bingbot/i, /yandex/i,
  301. /facebookexternalhit/i, /twitterbot/i,
  302. /whatsapp/i, /telegram/i
  303. ]
  304. bot_patterns.any? { |pattern| user_agent.match?(pattern) }
  305. end
  306. # Parse user agent
  307. def self.parse_user_agent(ua)
  308. return { browser: 'Unknown', device: 'Unknown', os: 'Unknown' } if ua.blank?
  309. # Simple parsing (you can use a gem like browser for more accurate parsing)
  310. browser = case ua
  311. when /Chrome/i then 'Chrome'
  312. when /Firefox/i then 'Firefox'
  313. when /Safari/i then 'Safari'
  314. when /Edge/i then 'Edge'
  315. when /Opera/i then 'Opera'
  316. else 'Other'
  317. end
  318. device = case ua
  319. when /Mobile|Android|iPhone|iPad/i then 'Mobile'
  320. when /Tablet/i then 'Tablet'
  321. else 'Desktop'
  322. end
  323. os = case ua
  324. when /Windows/i then 'Windows'
  325. when /Mac OS X/i then 'macOS'
  326. when /Linux/i then 'Linux'
  327. when /Android/i then 'Android'
  328. when /iOS|iPhone|iPad/i then 'iOS'
  329. else 'Other'
  330. end
  331. { browser: browser, device: device, os: os }
  332. end
  333. # Extract post/page IDs from path
  334. def self.extract_content_ids(path)
  335. ids = { post_id: nil, page_id: nil }
  336. # Try to match blog post pattern
  337. if path.match?(/\/blog\/(.+)/)
  338. slug = path.split('/').last
  339. post = Post.find_by(slug: slug)
  340. ids[:post_id] = post&.id
  341. end
  342. # Try to match page pattern
  343. unless ids[:post_id]
  344. slug = path.split('/').reject(&:blank?).last
  345. page_obj = Page.find_by(slug: slug) if slug
  346. ids[:page_id] = page_obj&.id
  347. end
  348. ids
  349. end
  350. # Extract title from path for better tracking
  351. def self.extract_title_from_path(path)
  352. case path
  353. when '/'
  354. 'Home Page'
  355. when '/blog'
  356. 'Blog Index'
  357. when /\/blog\/(.+)/
  358. slug = $1
  359. post = Post.find_by(slug: slug)
  360. post&.title || "Blog Post: #{slug.humanize}"
  361. when /\/page\/(.+)/, /^\/(.+)$/
  362. slug = $1
  363. page = Page.find_by(slug: slug)
  364. page&.title || "#{slug.humanize} Page"
  365. else
  366. "#{path.split('/').last&.humanize || 'Page'}"
  367. end
  368. end
  369. end

app/models/personal_data_erasure_request.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PersonalDataErasureRequest < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. belongs_to :user
  4. validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  5. validates :token, presence: true, uniqueness: true
  6. validates :status, presence: true
  7. enum status: {
  8. pending_confirmation: 'pending_confirmation',
  9. processing: 'processing',
  10. completed: 'completed',
  11. failed: 'failed',
  12. cancelled: 'cancelled'
  13. }, _suffix: true
  14. scope :recent, -> { order(created_at: :desc) }
  15. scope :awaiting_confirmation, -> { where(status: 'pending_confirmation') }
  16. after_create :generate_token
  17. private
  18. def generate_token
  19. self.token ||= SecureRandom.hex(32)
  20. end
  21. end

app/models/personal_data_export_request.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PersonalDataExportRequest < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. belongs_to :user
  4. validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  5. validates :token, presence: true, uniqueness: true
  6. validates :status, presence: true
  7. enum status: {
  8. pending: 'pending',
  9. processing: 'processing',
  10. completed: 'completed',
  11. failed: 'failed'
  12. }, _suffix: true
  13. scope :recent, -> { order(created_at: :desc) }
  14. scope :pending_expiry, -> { where('completed_at < ?', 7.days.ago) }
  15. after_create :generate_token
  16. private
  17. def generate_token
  18. self.token ||= SecureRandom.hex(32)
  19. end
  20. end

app/models/pixel.rb

0.0% lines covered

100.0% branches covered

324 relevant lines. 0 lines covered and 324 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Pixel < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Versioning
  5. has_paper_trail
  6. # Enums
  7. enum pixel_type: {
  8. google_analytics: 0,
  9. google_tag_manager: 1,
  10. facebook_pixel: 2,
  11. tiktok_pixel: 3,
  12. linkedin_insight: 4,
  13. twitter_pixel: 5,
  14. pinterest_tag: 6,
  15. snapchat_pixel: 7,
  16. reddit_pixel: 8,
  17. hotjar: 9,
  18. clarity: 10,
  19. mixpanel: 11,
  20. segment: 12,
  21. heap: 13,
  22. custom: 99
  23. }
  24. enum position: {
  25. head: 0, # <head> section
  26. body_start: 1, # After <body>
  27. body_end: 2 # Before </body>
  28. }
  29. # Validations
  30. validates :name, presence: true
  31. validates :pixel_type, presence: true
  32. validates :position, presence: true
  33. validate :requires_pixel_id_or_custom_code
  34. validate :custom_code_is_safe
  35. # Scopes
  36. scope :active, -> { where(active: true) }
  37. scope :inactive, -> { where(active: false) }
  38. scope :by_position, ->(pos) { where(position: pos) }
  39. scope :by_provider, ->(provider) { where(provider: provider) }
  40. scope :ordered, -> { order(position: :asc, created_at: :asc) }
  41. # Instance methods
  42. # Render the pixel code
  43. def render_code
  44. return '' unless active?
  45. if custom?
  46. sanitize_custom_code(custom_code || '')
  47. else
  48. generate_provider_code
  49. end
  50. end
  51. # Check if pixel is properly configured
  52. def configured?
  53. if custom?
  54. custom_code.present?
  55. else
  56. pixel_id.present?
  57. end
  58. end
  59. private
  60. def requires_pixel_id_or_custom_code
  61. if custom? && custom_code.blank?
  62. errors.add(:custom_code, "can't be blank for custom pixels")
  63. elsif !custom? && pixel_id.blank?
  64. errors.add(:pixel_id, "can't be blank for #{pixel_type} pixels")
  65. end
  66. end
  67. def custom_code_is_safe
  68. return unless custom_code.present?
  69. # Basic security checks
  70. dangerous_patterns = [
  71. /<script[^>]*src=/i, # External scripts
  72. /eval\(/i, # eval() calls
  73. /document\.write/i, # document.write
  74. /on\w+=/i # Inline event handlers
  75. ]
  76. dangerous_patterns.each do |pattern|
  77. if custom_code.match?(pattern)
  78. errors.add(:custom_code, "contains potentially dangerous code pattern")
  79. break
  80. end
  81. end
  82. end
  83. def sanitize_custom_code(code)
  84. # Return code as-is but wrapped in comment for admin reference
  85. # In production, you might want more strict sanitization
  86. code
  87. end
  88. def generate_provider_code
  89. case pixel_type.to_sym
  90. when :google_analytics
  91. google_analytics_code
  92. when :google_tag_manager
  93. google_tag_manager_code
  94. when :facebook_pixel
  95. facebook_pixel_code
  96. when :tiktok_pixel
  97. tiktok_pixel_code
  98. when :linkedin_insight
  99. linkedin_insight_code
  100. when :twitter_pixel
  101. twitter_pixel_code
  102. when :pinterest_tag
  103. pinterest_tag_code
  104. when :snapchat_pixel
  105. snapchat_pixel_code
  106. when :reddit_pixel
  107. reddit_pixel_code
  108. when :hotjar
  109. hotjar_code
  110. when :clarity
  111. clarity_code
  112. when :mixpanel
  113. mixpanel_code
  114. when :segment
  115. segment_code
  116. when :heap
  117. heap_code
  118. else
  119. ''
  120. end
  121. end
  122. # Provider-specific code generators
  123. def google_analytics_code
  124. <<~HTML
  125. <!-- Google Analytics -->
  126. <script async src="https://www.googletagmanager.com/gtag/js?id=#{pixel_id}"></script>
  127. <script>
  128. window.dataLayer = window.dataLayer || [];
  129. function gtag(){dataLayer.push(arguments);}
  130. gtag('js', new Date());
  131. gtag('config', '#{pixel_id}');
  132. </script>
  133. HTML
  134. end
  135. def google_tag_manager_code
  136. if position.to_sym == :head || position.to_sym == :body_start
  137. <<~HTML
  138. <!-- Google Tag Manager -->
  139. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  140. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  141. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  142. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  143. })(window,document,'script','dataLayer','#{pixel_id}');</script>
  144. <!-- End Google Tag Manager -->
  145. HTML
  146. else
  147. <<~HTML
  148. <!-- Google Tag Manager (noscript) -->
  149. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{pixel_id}"
  150. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  151. <!-- End Google Tag Manager (noscript) -->
  152. HTML
  153. end
  154. end
  155. def facebook_pixel_code
  156. <<~HTML
  157. <!-- Meta Pixel Code -->
  158. <script>
  159. !function(f,b,e,v,n,t,s)
  160. {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
  161. n.callMethod.apply(n,arguments):n.queue.push(arguments)};
  162. if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
  163. n.queue=[];t=b.createElement(e);t.async=!0;
  164. t.src=v;s=b.getElementsByTagName(e)[0];
  165. s.parentNode.insertBefore(t,s)}(window, document,'script',
  166. 'https://connect.facebook.net/en_US/fbevents.js');
  167. fbq('init', '#{pixel_id}');
  168. fbq('track', 'PageView');
  169. </script>
  170. <noscript><img height="1" width="1" style="display:none"
  171. src="https://www.facebook.com/tr?id=#{pixel_id}&ev=PageView&noscript=1"
  172. /></noscript>
  173. <!-- End Meta Pixel Code -->
  174. HTML
  175. end
  176. def tiktok_pixel_code
  177. <<~HTML
  178. <!-- TikTok Pixel Code -->
  179. <script>
  180. !function (w, d, t) {
  181. w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
  182. ttq.load('#{pixel_id}');
  183. ttq.page();
  184. }(window, document, 'ttq');
  185. </script>
  186. <!-- End TikTok Pixel Code -->
  187. HTML
  188. end
  189. def linkedin_insight_code
  190. <<~HTML
  191. <!-- LinkedIn Insight Tag -->
  192. <script type="text/javascript">
  193. _linkedin_partner_id = "#{pixel_id}";
  194. window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
  195. window._linkedin_data_partner_ids.push(_linkedin_partner_id);
  196. </script><script type="text/javascript">
  197. (function(l) {
  198. if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
  199. window.lintrk.q=[]}
  200. var s = document.getElementsByTagName("script")[0];
  201. var b = document.createElement("script");
  202. b.type = "text/javascript";b.async = true;
  203. b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
  204. s.parentNode.insertBefore(b, s);})(window.lintrk);
  205. </script>
  206. <noscript>
  207. <img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid=#{pixel_id}&fmt=gif" />
  208. </noscript>
  209. <!-- End LinkedIn Insight Tag -->
  210. HTML
  211. end
  212. def twitter_pixel_code
  213. <<~HTML
  214. <!-- Twitter Pixel Code -->
  215. <script>
  216. !function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
  217. },s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js',
  218. a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script');
  219. twq('config','#{pixel_id}');
  220. </script>
  221. <!-- End Twitter Pixel Code -->
  222. HTML
  223. end
  224. def pinterest_tag_code
  225. <<~HTML
  226. <!-- Pinterest Tag -->
  227. <script>
  228. !function(e){if(!window.pintrk){window.pintrk = function () {
  229. window.pintrk.queue.push(Array.prototype.slice.call(arguments))};var
  230. n=window.pintrk;n.queue=[],n.version="3.0";var
  231. t=document.createElement("script");t.async=!0,t.src=e;var
  232. r=document.getElementsByTagName("script")[0];
  233. r.parentNode.insertBefore(t,r)}}("https://s.pinimg.com/ct/core.js");
  234. pintrk('load', '#{pixel_id}', {em: '<user_email_address>'});
  235. pintrk('page');
  236. </script>
  237. <noscript>
  238. <img height="1" width="1" style="display:none;" alt=""
  239. src="https://ct.pinterest.com/v3/?event=init&tid=#{pixel_id}&noscript=1" />
  240. </noscript>
  241. <!-- End Pinterest Tag -->
  242. HTML
  243. end
  244. def snapchat_pixel_code
  245. <<~HTML
  246. <!-- Snapchat Pixel Code -->
  247. <script type='text/javascript'>
  248. (function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function()
  249. {a.handleRequest?a.handleRequest.apply(a,arguments):a.queue.push(arguments)};
  250. a.queue=[];var s='script';r=t.createElement(s);r.async=!0;
  251. r.src=n;var u=t.getElementsByTagName(s)[0];
  252. u.parentNode.insertBefore(r,u);})(window,document,
  253. 'https://sc-static.net/scevent.min.js');
  254. snaptr('init', '#{pixel_id}', {
  255. 'user_email': '__INSERT_USER_EMAIL__'
  256. });
  257. snaptr('track', 'PAGE_VIEW');
  258. </script>
  259. <!-- End Snapchat Pixel Code -->
  260. HTML
  261. end
  262. def reddit_pixel_code
  263. <<~HTML
  264. <!-- Reddit Pixel -->
  265. <script>
  266. !function(w,d){if(!w.rdt){var p=w.rdt=function(){p.sendEvent?p.sendEvent.apply(p,arguments):p.callQueue.push(arguments)};p.callQueue=[];var t=d.createElement("script");t.src="https://www.redditstatic.com/ads/pixel.js",t.async=!0;var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(t,s)}}(window,document);
  267. rdt('init','#{pixel_id}');
  268. rdt('track', 'PageVisit');
  269. </script>
  270. <!-- End Reddit Pixel -->
  271. HTML
  272. end
  273. def hotjar_code
  274. <<~HTML
  275. <!-- Hotjar Tracking Code -->
  276. <script>
  277. (function(h,o,t,j,a,r){
  278. h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
  279. h._hjSettings={hjid:#{pixel_id},hjsv:6};
  280. a=o.getElementsByTagName('head')[0];
  281. r=o.createElement('script');r.async=1;
  282. r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
  283. a.appendChild(r);
  284. })(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
  285. </script>
  286. <!-- End Hotjar Tracking Code -->
  287. HTML
  288. end
  289. def clarity_code
  290. <<~HTML
  291. <!-- Microsoft Clarity -->
  292. <script type="text/javascript">
  293. (function(c,l,a,r,i,t,y){
  294. c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
  295. t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
  296. y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
  297. })(window, document, "clarity", "script", "#{pixel_id}");
  298. </script>
  299. <!-- End Microsoft Clarity -->
  300. HTML
  301. end
  302. def mixpanel_code
  303. <<~HTML
  304. <!-- Mixpanel -->
  305. <script type="text/javascript">
  306. (function(f,b){if(!b.__SV){var e,g,i,h;window.mixpanel=b;b._i=[];b.init=function(e,f,c){function g(a,d){var b=d.split(".");2==b.length&&(a=a[b[0]],d=b[1]);a[d]=function(){a.push([d].concat(Array.prototype.slice.call(arguments,0)))}}var a=b;"undefined"!==typeof c?a=b[c]=[]:c="mixpanel";a.people=a.people||[];a.toString=function(a){var d="mixpanel";"mixpanel"!==c&&(d+="."+c);a||(d+=" (stub)");return d};a.people.toString=function(){return a.toString(1)+".people (stub)"};i="disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(" ");
  307. for(h=0;h<i.length;h++)g(a,i[h]);var j="set set_once union unset remove delete".split(" ");a.get_group=function(){function b(c){d[c]=function(){call2_args=arguments;call2=[c].concat(Array.prototype.slice.call(call2_args,0));a.push([e,call2])}}for(var d={},e=["get_group"].concat(Array.prototype.slice.call(arguments,0)),c=0;c<j.length;c++)b(j[c]);return d};b._i.push([e,f,c])};b.__SV=1.2;e=f.createElement("script");e.type="text/javascript";e.async=!0;e.src="undefined"!==typeof MIXPANEL_CUSTOM_LIB_URL?
  308. MIXPANEL_CUSTOM_LIB_URL:"file:"===f.location.protocol&&"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\\/\\//)?"https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js":"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";g=f.getElementsByTagName("script")[0];g.parentNode.insertBefore(e,g)}})(document,window.mixpanel||[]);
  309. mixpanel.init("#{pixel_id}");
  310. </script>
  311. <!-- End Mixpanel -->
  312. HTML
  313. end
  314. def segment_code
  315. <<~HTML
  316. <!-- Segment -->
  317. <script type="text/javascript">
  318. !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware"];analytics.factory=function(e){return function(){var t=Array.prototype.slice.call(arguments);t.unshift(e);analytics.push(t);return analytics}};for(var e=0;e<analytics.methods.length;e++){var key=analytics.methods[e];analytics[key]=analytics.factory(key)}analytics.load=function(key,e){var t=document.createElement("script");t.type="text/javascript";t.async=!0;t.src="https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);analytics._loadOptions=e};analytics._writeKey="#{pixel_id}";;analytics.SNIPPET_VERSION="4.15.3";
  319. analytics.load("#{pixel_id}");
  320. analytics.page();
  321. }}();
  322. </script>
  323. <!-- End Segment -->
  324. HTML
  325. end
  326. def heap_code
  327. <<~HTML
  328. <!-- Heap Analytics -->
  329. <script type="text/javascript">
  330. window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var r=document.createElement("script");r.type="text/javascript",r.async=!0,r.src="https://cdn.heapanalytics.com/js/heap-"+e+".js";var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(r,a);for(var n=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["addEventProperties","addUserProperties","clearEventProperties","identify","resetIdentity","removeEventProperty","setEventProperties","track","unsetEventProperty"],o=0;o<p.length;o++)heap[p[o]]=n(p[o])};
  331. heap.load("#{pixel_id}");
  332. </script>
  333. <!-- End Heap Analytics -->
  334. HTML
  335. end
  336. end

app/models/plugin.rb

71.43% lines covered

0.0% branches covered

14 relevant lines. 10 lines covered and 4 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. 1 class Plugin < ApplicationRecord
  2. # Serialization
  3. 1 serialize :settings, coder: JSON, type: Hash
  4. # Validations
  5. 1 validates :name, presence: true, uniqueness: true
  6. 1 validates :version, presence: true
  7. # Scopes
  8. 2 scope :active, -> { where(active: true) }
  9. # Callbacks
  10. 1 after_initialize :set_defaults, if: :new_record?
  11. # Methods
  12. 1 def activate!
  13. update(active: true)
  14. end
  15. 1 def deactivate!
  16. update(active: false)
  17. end
  18. 1 private
  19. 1 def set_defaults
  20. then: 0 else: 0 self.active = false if active.nil?
  21. self.settings ||= {}
  22. end
  23. end

app/models/plugin_setting.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PluginSetting < ApplicationRecord
  2. # Validations
  3. validates :plugin_name, presence: true
  4. validates :key, presence: true, uniqueness: { scope: :plugin_name }
  5. validates :setting_type, inclusion: { in: %w[string boolean integer float array json text] }, allow_nil: true
  6. # Scopes
  7. scope :for_plugin, ->(plugin_name) { where(plugin_name: plugin_name) }
  8. scope :by_key, ->(key) { where(key: key) }
  9. # Callbacks
  10. before_save :set_default_type
  11. # Get typed value
  12. def typed_value
  13. case setting_type
  14. when 'boolean'
  15. value == 'true' || value == '1' || value == true
  16. when 'integer'
  17. value.to_i
  18. when 'float'
  19. value.to_f
  20. when 'array', 'json'
  21. JSON.parse(value) rescue []
  22. else
  23. value
  24. end
  25. end
  26. # Set typed value
  27. def typed_value=(new_value)
  28. case setting_type
  29. when 'boolean'
  30. self.value = new_value.to_s
  31. when 'integer', 'float'
  32. self.value = new_value.to_s
  33. when 'array', 'json'
  34. self.value = new_value.to_json
  35. else
  36. self.value = new_value.to_s
  37. end
  38. end
  39. # Class method to get setting
  40. def self.get(plugin_name, key, default = nil)
  41. setting = find_by(plugin_name: plugin_name, key: key)
  42. setting ? setting.typed_value : default
  43. end
  44. # Class method to set setting
  45. def self.set(plugin_name, key, value, type = 'string')
  46. setting = find_or_initialize_by(plugin_name: plugin_name, key: key)
  47. setting.setting_type = type
  48. setting.typed_value = value
  49. setting.save!
  50. setting
  51. end
  52. private
  53. def set_default_type
  54. self.setting_type ||= 'string'
  55. end
  56. end

app/models/post.rb

0.0% lines covered

100.0% branches covered

232 relevant lines. 0 lines covered and 232 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Post < ApplicationRecord
  2. include Railspress::ChannelDetection
  3. # Multi-tenancy
  4. # acts_as_tenant(:tenant, optional: true) # Temporarily disabled for testing
  5. # Trash functionality
  6. include Trashable
  7. # Soft deletes
  8. include Discard::Model
  9. self.discard_column = :deleted_at
  10. # Versioning
  11. has_paper_trail
  12. # Versioning methods
  13. def versions_count
  14. versions.count
  15. end
  16. def latest_version
  17. versions.last
  18. end
  19. def version_at(timestamp)
  20. versions.where('created_at <= ?', timestamp).order(:created_at).last
  21. end
  22. def changes_since(version)
  23. return {} unless version
  24. current_changes = {}
  25. version.changeset.each do |field, change|
  26. current_changes[field] = {
  27. from: change[0],
  28. to: change[1],
  29. current: send(field)
  30. }
  31. end
  32. current_changes
  33. end
  34. def restore_to_version(version_id)
  35. version = versions.find(version_id)
  36. return false unless version
  37. # Create a backup of current version before restoring
  38. PaperTrail.without_versioning do
  39. version.reify.save!
  40. end
  41. true
  42. rescue => e
  43. Rails.logger.error "Failed to restore version #{version_id}: #{e.message}"
  44. false
  45. end
  46. def version_summary(version)
  47. changes = version.changeset
  48. return "Initial version" if changes.empty?
  49. summary_parts = []
  50. summary_parts << "Title changed" if changes.key?('title')
  51. summary_parts << "Content updated" if changes.key?('content')
  52. summary_parts << "Status changed" if changes.key?('status')
  53. summary_parts << "SEO updated" if changes.key?('meta_title') || changes.key?('meta_description')
  54. summary_parts.any? ? summary_parts.join(', ') : "Minor changes"
  55. end
  56. # Search - Database agnostic
  57. def self.search_full_text(query)
  58. return none if query.blank?
  59. # Simple LIKE search that works with all databases
  60. query_pattern = "%#{query}%"
  61. where(
  62. "title LIKE ? OR excerpt LIKE ? OR meta_description LIKE ? OR content LIKE ?",
  63. query_pattern, query_pattern, query_pattern, query_pattern
  64. )
  65. end
  66. # Custom Taxonomies
  67. include HasTaxonomies
  68. # Meta fields for plugin extensibility
  69. has_many :meta_fields, as: :metable, dependent: :destroy
  70. include Metable
  71. # Set up taxonomy associations
  72. has_taxonomy :category
  73. has_taxonomy :post_tag
  74. # SEO
  75. include SeoOptimizable
  76. belongs_to :user
  77. belongs_to :content_type, optional: true
  78. # Alias for semantic clarity
  79. def author
  80. user
  81. end
  82. # Get content type or default to 'post'
  83. def post_type
  84. content_type || ContentType.default_type
  85. end
  86. def post_type_ident
  87. post_type&.ident || 'post'
  88. end
  89. def author=(value)
  90. self.user = value
  91. end
  92. # Rich text content
  93. has_rich_text :content
  94. # Media/image support
  95. has_one_attached :featured_image_file
  96. # Channels
  97. has_and_belongs_to_many :channels
  98. # Associations
  99. has_many :comments, as: :commentable, dependent: :destroy
  100. # Status enum (like WordPress)
  101. enum status: {
  102. draft: 0,
  103. published: 1,
  104. scheduled: 2,
  105. pending_review: 3,
  106. private_post: 4,
  107. trash: 5
  108. }, _suffix: true
  109. # Status scopes
  110. scope :visible_to_public, -> {
  111. kept.where(status: [:published, :scheduled])
  112. .where('published_at IS NULL OR published_at <= ?', Time.current)
  113. }
  114. scope :not_trashed, -> { where.not(status: :trash) }
  115. scope :trashed, -> { where(status: :trash) }
  116. scope :awaiting_review, -> { where(status: :pending_review) }
  117. scope :scheduled_future, -> {
  118. where(status: :scheduled)
  119. .where('published_at > ?', Time.current)
  120. }
  121. scope :scheduled_past, -> {
  122. where(status: :scheduled)
  123. .where('published_at <= ?', Time.current)
  124. }
  125. # Check if post should be visible to public (without password check)
  126. def visible_to_public?
  127. return false if trash_status?
  128. return false if draft_status?
  129. return false if pending_review_status?
  130. return false if private_post_status? # Only for logged-in users
  131. if scheduled_status?
  132. published_at.present? && published_at <= Time.current
  133. else
  134. published_status?
  135. end
  136. end
  137. # Check if post is password protected
  138. def password_protected?
  139. password.present?
  140. end
  141. # Check if provided password is correct
  142. def password_matches?(input_password)
  143. return true unless password_protected?
  144. password == input_password
  145. end
  146. # Auto-publish scheduled posts
  147. def check_scheduled_publish
  148. if scheduled_status? && published_at.present? && published_at <= Time.current
  149. update(status: :published)
  150. end
  151. end
  152. # Friendly ID for slugs
  153. extend FriendlyId
  154. friendly_id :title, use: :slugged
  155. # Validations
  156. validates :title, presence: true
  157. validates :slug, presence: true, uniqueness: { scope: :tenant_id }
  158. validates :status, presence: true
  159. validates :password, length: { minimum: 4 }, allow_blank: true
  160. # Scopes
  161. scope :published, -> { where(status: 'published').where('published_at <= ?', Time.current) }
  162. scope :scheduled, -> { where(status: 'scheduled').where('published_at > ?', Time.current) }
  163. scope :recent, -> { order(published_at: :desc) }
  164. scope :by_category, ->(category) { joins(:terms).where(terms: { slug: category }).joins('INNER JOIN taxonomies ON terms.taxonomy_id = taxonomies.id').where(taxonomies: { slug: 'category' }) }
  165. scope :by_tag, ->(tag) { joins(:terms).where(terms: { slug: tag }).joins('INNER JOIN taxonomies ON terms.taxonomy_id = taxonomies.id').where(taxonomies: { slug: 'post_tag' }) }
  166. scope :search, ->(query) { where("title ILIKE ? OR content ILIKE ?", "%#{query}%", "%#{query}%") }
  167. # Callbacks
  168. before_validation :set_published_at, if: :published_status?
  169. after_create :trigger_post_created_hook
  170. after_update :trigger_post_updated_hook, if: :saved_change_to_status?
  171. # Methods
  172. def should_generate_new_friendly_id?
  173. title_changed? || slug.blank?
  174. end
  175. def author_name
  176. user&.name || user&.email&.split('@')&.first&.titleize || 'Anonymous'
  177. end
  178. private
  179. def set_published_at
  180. self.published_at ||= Time.current
  181. end
  182. def trigger_post_created_hook
  183. Railspress::PluginSystem.do_action('post_created', self)
  184. Railspress::WebhookDispatcher.dispatch('post.created', self)
  185. end
  186. def trigger_post_updated_hook
  187. if published_status?
  188. Railspress::PluginSystem.do_action('post_published', self)
  189. Railspress::WebhookDispatcher.dispatch('post.published', self)
  190. end
  191. Railspress::PluginSystem.do_action('post_updated', self)
  192. Railspress::WebhookDispatcher.dispatch('post.updated', self)
  193. end
  194. # SEO URL override
  195. def seo_default_url
  196. Rails.application.routes.url_helpers.blog_post_url(slug)
  197. rescue
  198. "#"
  199. end
  200. # Featured image URL for SEO
  201. def featured_image_url
  202. return nil unless featured_image_file.attached?
  203. Rails.application.routes.url_helpers.url_for(featured_image_file)
  204. rescue
  205. nil
  206. end
  207. # Custom Fields (ACF-style)
  208. has_many :custom_field_values, dependent: :destroy
  209. # Get field value by name
  210. def get_field(field_name)
  211. value = custom_field_values.by_key(field_name.to_s).first
  212. value&.typed_value
  213. end
  214. # Set field value by name
  215. def set_field(field_name, field_value)
  216. field = CustomField.joins(:field_group)
  217. .where('custom_fields.name = ?', field_name.to_s)
  218. .where('field_groups.active = ?', true)
  219. .first
  220. return false unless field
  221. value_record = custom_field_values.find_or_initialize_by(
  222. custom_field: field,
  223. meta_key: field_name.to_s
  224. )
  225. value_record.typed_value = field_value
  226. value_record.save
  227. end
  228. # Get all fields as hash
  229. def get_fields
  230. custom_field_values.includes(:custom_field).each_with_object({}) do |cfv, hash|
  231. hash[cfv.meta_key] = cfv.typed_value
  232. end
  233. end
  234. # Update multiple fields at once
  235. def update_fields(fields_hash)
  236. fields_hash.each do |key, value|
  237. set_field(key, value)
  238. end
  239. end
  240. # Get field groups that should be shown for this post
  241. def applicable_field_groups
  242. FieldGroup.active.ordered.select { |fg| fg.matches_location?(self) }
  243. end
  244. # Generate URL for the post
  245. def url
  246. Rails.application.routes.url_helpers.blog_post_url(self.id, host: 'localhost:3000')
  247. end
  248. # Get the author of the post
  249. def author
  250. User.find_by(id: self.user_id)
  251. end
  252. # Get categories for the post
  253. def categories
  254. # This would need to be implemented based on your taxonomy system
  255. # For now, return an empty array to prevent errors
  256. []
  257. end
  258. # Convert Post to Liquid-compatible hash
  259. def to_liquid
  260. {
  261. 'id' => id,
  262. 'title' => title,
  263. 'content' => content.to_s, # Convert ActionText to string
  264. 'excerpt' => excerpt,
  265. 'url' => url,
  266. 'author' => author,
  267. 'categories' => categories.to_a, # Convert AssociationRelation to array
  268. 'terms' => terms.to_a, # Convert AssociationRelation to array
  269. 'published_at' => published_at,
  270. 'created_at' => created_at,
  271. 'updated_at' => updated_at,
  272. 'featured_image' => featured_image
  273. }
  274. end
  275. # Make these methods public for Liquid access
  276. public :url, :author, :categories, :to_liquid
  277. end

app/models/published_theme_file.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PublishedThemeFile < ApplicationRecord
  2. belongs_to :published_theme_version
  3. # Scopes
  4. scope :templates, -> { where(file_type: 'template') }
  5. scope :sections, -> { where(file_type: 'section') }
  6. scope :layouts, -> { where(file_type: 'layout') }
  7. scope :assets, -> { where(file_type: 'asset') }
  8. scope :configs, -> { where(file_type: 'config') }
  9. end

app/models/published_theme_version.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PublishedThemeVersion < ApplicationRecord
  2. belongs_to :published_by, polymorphic: true
  3. belongs_to :tenant
  4. belongs_to :theme
  5. has_many :published_theme_files, dependent: :destroy
  6. # Scopes
  7. scope :for_theme, ->(theme_or_name) {
  8. if theme_or_name.is_a?(Theme)
  9. where(theme: theme_or_name)
  10. else
  11. joins(:theme).where("LOWER(themes.name) = ?", theme_or_name.to_s.downcase)
  12. end
  13. }
  14. scope :latest, -> { order(version_number: :desc) }
  15. # Get file content
  16. def file_content(file_path)
  17. file = published_theme_files.find_by(file_path: file_path)
  18. file&.content
  19. end
  20. # Get parsed JSON file
  21. def parsed_file(file_path)
  22. content = file_content(file_path)
  23. return nil unless content
  24. JSON.parse(content)
  25. rescue JSON::ParserError
  26. nil
  27. end
  28. # Liquid file system compatibility methods
  29. def read_template_file(template_path)
  30. Rails.logger.info "PublishedVersion: Looking for template: #{template_path}"
  31. # Try to find the file directly
  32. file = published_theme_files.find_by(file_path: template_path)
  33. if file
  34. Rails.logger.info "PublishedVersion: Found template file: #{template_path}"
  35. return file.content
  36. end
  37. # Try with .liquid extension
  38. file = published_theme_files.find_by(file_path: "#{template_path}.liquid")
  39. if file
  40. Rails.logger.info "PublishedVersion: Found template file with .liquid: #{template_path}.liquid"
  41. return file.content
  42. end
  43. # Try snippets directory
  44. file = published_theme_files.find_by(file_path: "snippets/#{template_path}.liquid")
  45. if file
  46. Rails.logger.info "PublishedVersion: Found snippet file: snippets/#{template_path}.liquid"
  47. return file.content
  48. end
  49. Rails.logger.warn "PublishedVersion: Template file not found: #{template_path}"
  50. # Fallback to empty string
  51. ""
  52. end
  53. end

app/models/redirect.rb

0.0% lines covered

100.0% branches covered

140 relevant lines. 0 lines covered and 140 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Redirect < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Versioning
  5. has_paper_trail
  6. # Enums
  7. enum redirect_type: {
  8. permanent: 0, # 301 - Permanent redirect
  9. temporary: 1, # 302 - Temporary redirect
  10. see_other: 2, # 303 - See Other
  11. temporary_new: 3 # 307 - Temporary Redirect (preserves method)
  12. }
  13. # Validations
  14. validates :from_path, presence: true, uniqueness: { scope: :tenant_id }
  15. validates :to_path, presence: true
  16. validates :status_code, inclusion: { in: [301, 302, 303, 307, 308] }
  17. validate :paths_are_different
  18. validate :no_circular_redirects
  19. validate :from_path_format
  20. # Scopes
  21. scope :active, -> { where(active: true) }
  22. scope :inactive, -> { where(active: false) }
  23. scope :by_type, ->(type) { where(redirect_type: type) }
  24. scope :most_used, -> { order(hits_count: :desc) }
  25. scope :recent, -> { order(created_at: :desc) }
  26. # Callbacks
  27. before_validation :normalize_paths
  28. after_initialize :set_default_status_code
  29. # Instance methods
  30. # Increment hit counter
  31. def record_hit!
  32. increment!(:hits_count)
  33. end
  34. # Get the appropriate HTTP status code
  35. def http_status_code
  36. case redirect_type.to_sym
  37. when :permanent
  38. 301
  39. when :temporary
  40. 302
  41. when :see_other
  42. 303
  43. when :temporary_new
  44. 307
  45. else
  46. status_code || 301
  47. end
  48. end
  49. # Check if redirect matches a given path
  50. def matches?(path)
  51. return false unless active?
  52. # Exact match
  53. return true if from_path == path
  54. # Wildcard match (if from_path ends with *)
  55. if from_path.ends_with?('*')
  56. pattern = from_path.chomp('*')
  57. return path.starts_with?(pattern)
  58. end
  59. false
  60. end
  61. # Get the destination path for a given request path
  62. def destination_for(request_path)
  63. # Handle wildcard redirects
  64. if from_path.ends_with?('*') && request_path.starts_with?(from_path.chomp('*'))
  65. pattern = from_path.chomp('*')
  66. remainder = request_path[pattern.length..]
  67. return "#{to_path}#{remainder}"
  68. end
  69. to_path
  70. end
  71. private
  72. def normalize_paths
  73. # Ensure paths start with /
  74. self.from_path = "/#{from_path}" unless from_path&.start_with?('/')
  75. self.to_path = "/#{to_path}" unless to_path&.start_with?('/') || to_path&.start_with?('http')
  76. # Remove trailing slashes (except for root)
  77. self.from_path = from_path.chomp('/') if from_path && from_path.length > 1
  78. self.to_path = to_path.chomp('/') if to_path && to_path.length > 1 && !to_path.start_with?('http')
  79. end
  80. def paths_are_different
  81. if from_path.present? && to_path.present? && from_path == to_path
  82. errors.add(:to_path, "must be different from source path")
  83. end
  84. end
  85. def no_circular_redirects
  86. return unless from_path.present? && to_path.present?
  87. # Check if destination redirects back to source
  88. destination_redirect = Redirect.active
  89. .where(from_path: to_path)
  90. .where.not(id: id)
  91. .first
  92. if destination_redirect && destination_redirect.to_path == from_path
  93. errors.add(:to_path, "creates a circular redirect")
  94. end
  95. end
  96. def from_path_format
  97. return unless from_path.present?
  98. # Allow wildcard at end
  99. if from_path.include?('*') && !from_path.ends_with?('*')
  100. errors.add(:from_path, "wildcard (*) can only be used at the end")
  101. end
  102. end
  103. def set_default_status_code
  104. return if status_code.present?
  105. self.status_code = case redirect_type&.to_sym
  106. when :permanent
  107. 301
  108. when :temporary
  109. 302
  110. when :see_other
  111. 303
  112. when :temporary_new
  113. 307
  114. else
  115. 301
  116. end
  117. end
  118. # Class methods
  119. # Find redirect for a given path
  120. def self.find_for_path(path)
  121. active.find do |redirect|
  122. redirect.matches?(path)
  123. end
  124. end
  125. # Import redirects from CSV or array
  126. def self.import_redirects(data)
  127. imported = 0
  128. errors = []
  129. data.each do |row|
  130. redirect = new(
  131. from_path: row[:from_path] || row['from_path'],
  132. to_path: row[:to_path] || row['to_path'],
  133. redirect_type: row[:redirect_type] || row['redirect_type'] || 'permanent',
  134. notes: row[:notes] || row['notes']
  135. )
  136. if redirect.save
  137. imported += 1
  138. else
  139. errors << { row: row, errors: redirect.errors.full_messages }
  140. end
  141. end
  142. { imported: imported, errors: errors }
  143. end
  144. # Export to CSV format
  145. def self.to_csv
  146. require 'csv'
  147. CSV.generate(headers: true) do |csv|
  148. csv << ['From Path', 'To Path', 'Type', 'Status Code', 'Active', 'Hits', 'Notes']
  149. all.each do |redirect|
  150. csv << [
  151. redirect.from_path,
  152. redirect.to_path,
  153. redirect.redirect_type,
  154. redirect.status_code,
  155. redirect.active,
  156. redirect.hits_count,
  157. redirect.notes
  158. ]
  159. end
  160. end
  161. end
  162. end

app/models/shortcut.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Shortcut < ApplicationRecord
  2. # Make tenant optional since shortcuts can be global
  3. belongs_to :tenant, optional: true
  4. CATEGORIES = %w[navigation content tools settings].freeze
  5. ACTION_TYPES = %w[navigate execute modal].freeze
  6. validates :name, presence: true
  7. validates :action_type, presence: true, inclusion: { in: ACTION_TYPES }
  8. validates :category, inclusion: { in: CATEGORIES }, allow_nil: true
  9. scope :active, -> { where(active: true) }
  10. scope :by_category, ->(category) { where(category: category) }
  11. scope :ordered, -> { order(:category, :position, :name) }
  12. after_initialize :set_defaults, if: :new_record?
  13. def execute(context = {})
  14. case action_type
  15. when 'navigate'
  16. # Return URL to navigate to
  17. action_value
  18. when 'execute'
  19. # Return JavaScript to execute
  20. action_value
  21. when 'modal'
  22. # Return modal to open
  23. action_value
  24. end
  25. end
  26. private
  27. def set_defaults
  28. self.active = true if active.nil?
  29. self.position ||= 0
  30. self.category ||= 'navigation'
  31. end
  32. end

app/models/site_setting.rb

54.55% lines covered

16.67% branches covered

22 relevant lines. 12 lines covered and 10 lines missed.
12 total branches, 2 branches covered and 10 branches missed.
    
  1. 1 class SiteSetting < ApplicationRecord
  2. # Multi-tenancy
  3. 1 acts_as_tenant(:tenant, optional: true)
  4. # Validations
  5. 1 validates :key, presence: true
  6. 1 validates :key, uniqueness: { scope: :tenant_id }
  7. 1 validates :setting_type, presence: true
  8. # Setting types
  9. 1 SETTING_TYPES = %w[string integer boolean text].freeze
  10. 1 validates :setting_type, inclusion: { in: SETTING_TYPES }
  11. # Class methods for easy access
  12. 1 def self.get(key, default = nil)
  13. 9 then: 0 setting = ActsAsTenant.current_tenant ?
  14. else: 9 where(tenant: ActsAsTenant.current_tenant).find_by(key: key) :
  15. find_by(key: key)
  16. 9 then: 0 else: 9 setting ? setting.typed_value : default
  17. end
  18. 1 def self.set(key, value, setting_type = 'string')
  19. then: 0 setting = ActsAsTenant.current_tenant ?
  20. else: 0 where(tenant: ActsAsTenant.current_tenant).find_or_initialize_by(key: key) :
  21. find_or_initialize_by(key: key)
  22. setting.value = value.to_s
  23. setting.setting_type = setting_type
  24. then: 0 else: 0 setting.tenant = ActsAsTenant.current_tenant if ActsAsTenant.current_tenant
  25. setting.save
  26. end
  27. # Instance methods
  28. 1 def typed_value
  29. case setting_type
  30. when: 0 when 'integer'
  31. value.to_i
  32. when: 0 when 'boolean'
  33. value == 'true' || value == '1'
  34. when: 0 when 'text', 'string'
  35. value
  36. else: 0 else
  37. value
  38. end
  39. end
  40. end

app/models/slick_form.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForm Model
  2. # Represents a form created with SlickForms plugin
  3. class SlickForm < ApplicationRecord
  4. # Associations
  5. has_many :slick_form_submissions, dependent: :destroy
  6. # Validations
  7. validates :name, presence: true, uniqueness: { scope: :tenant_id }
  8. validates :title, presence: true
  9. validates :active, inclusion: { in: [true, false] }
  10. # Scopes
  11. scope :active, -> { where(active: true) }
  12. scope :inactive, -> { where(active: false) }
  13. scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
  14. scope :accessible_by, ->(tenant) { tenant ? where(tenant_id: tenant.id) : all }
  15. scope :recent, -> { order(created_at: :desc) }
  16. # JSON fields are handled natively by Rails 7+
  17. # Callbacks
  18. before_save :ensure_defaults
  19. # Methods
  20. def field_count
  21. fields&.size || 0
  22. end
  23. def has_submissions?
  24. submissions_count > 0
  25. end
  26. def conversion_rate
  27. return 0.0 unless views_count&.> 0
  28. submissions_count.to_f / views_count
  29. end
  30. def duplicate!
  31. new_form = dup
  32. new_form.name = "#{name} (Copy)"
  33. new_form.title = "#{title} (Copy)"
  34. new_form.submissions_count = 0
  35. new_form.save!
  36. new_form
  37. end
  38. def public_url
  39. "/plugins/slick_forms/form/#{id}"
  40. end
  41. def embed_url
  42. "/plugins/slick_forms/form/#{id}/embed"
  43. end
  44. def submission_url
  45. "/plugins/slick_forms/submit/#{id}"
  46. end
  47. private
  48. def ensure_defaults
  49. self.fields ||= []
  50. self.settings ||= {}
  51. self.submissions_count ||= 0
  52. end
  53. end

app/models/slick_form_submission.rb

0.0% lines covered

100.0% branches covered

88 relevant lines. 0 lines covered and 88 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickFormSubmission Model
  2. # Represents a form submission from SlickForms plugin
  3. class SlickFormSubmission < ApplicationRecord
  4. # Associations
  5. belongs_to :slick_form
  6. # Validations
  7. validates :data, presence: true
  8. # JSON fields are handled natively by Rails 7+
  9. # Scopes
  10. scope :spam, -> { where(spam: true) }
  11. scope :ham, -> { where(spam: false) }
  12. scope :recent, -> { order(created_at: :desc) }
  13. scope :accessible_by, ->(tenant) { tenant ? where(tenant_id: tenant.id) : all }
  14. scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
  15. scope :today, -> { where(created_at: Date.current.beginning_of_day..Date.current.end_of_day) }
  16. scope :this_week, -> { where(created_at: 1.week.ago..Time.current) }
  17. # Methods
  18. def spam?
  19. spam == true
  20. end
  21. def ham?
  22. spam == false
  23. end
  24. def mark_as_spam!
  25. update!(spam: true)
  26. end
  27. def mark_as_ham!
  28. update!(spam: false)
  29. end
  30. def data_field(field_name)
  31. return nil unless data.is_a?(Hash)
  32. data[field_name.to_s] || data[field_name.to_sym]
  33. end
  34. def email
  35. data_field('email')
  36. end
  37. def name
  38. data_field('name') || data_field('first_name')
  39. end
  40. def form_title
  41. slick_form&.title || 'Unknown Form'
  42. end
  43. def ip_location
  44. # This would integrate with a geolocation service
  45. 'Unknown'
  46. end
  47. def user_agent_parsed
  48. # This would parse user agent for browser/OS info
  49. user_agent
  50. end
  51. def to_csv_row
  52. [
  53. id,
  54. slick_form_id,
  55. form_title,
  56. name,
  57. email,
  58. data.to_json,
  59. ip_address,
  60. user_agent,
  61. spam? ? 'Yes' : 'No',
  62. created_at.strftime('%Y-%m-%d %H:%M:%S')
  63. ]
  64. end
  65. class << self
  66. def csv_headers
  67. [
  68. 'ID',
  69. 'Form ID',
  70. 'Form Name',
  71. 'Name',
  72. 'Email',
  73. 'Data',
  74. 'IP Address',
  75. 'User Agent',
  76. 'Spam',
  77. 'Created At'
  78. ]
  79. end
  80. def export_csv(submissions = all)
  81. require 'csv'
  82. CSV.generate do |csv|
  83. csv << csv_headers
  84. submissions.each { |submission| csv << submission.to_csv_row }
  85. end
  86. end
  87. def spam_count
  88. spam.count
  89. end
  90. def ham_count
  91. ham.count
  92. end
  93. def total_count
  94. count
  95. end
  96. end
  97. end

app/models/storage_provider.rb

0.0% lines covered

100.0% branches covered

86 relevant lines. 0 lines covered and 86 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class StorageProvider < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Validations
  5. validates :name, presence: true
  6. validates :provider_type, presence: true, inclusion: { in: %w[local s3 gcs azure] }
  7. validates :config, presence: true
  8. # Scopes
  9. scope :active, -> { where(active: true) }
  10. scope :ordered, -> { order(:position, :name) }
  11. scope :by_type, ->(type) { where(provider_type: type) }
  12. # Serialization
  13. serialize :config, JSON
  14. # Callbacks
  15. before_validation :set_default_position
  16. after_update :update_active_storage_config, if: :saved_change_to_active?
  17. # Methods
  18. def local?
  19. provider_type == 'local'
  20. end
  21. def s3?
  22. provider_type == 's3'
  23. end
  24. def gcs?
  25. provider_type == 'gcs'
  26. end
  27. def azure?
  28. provider_type == 'azure'
  29. end
  30. def active_storage_service
  31. case provider_type
  32. when 'local'
  33. :local
  34. when 's3'
  35. :amazon
  36. when 'gcs'
  37. :google
  38. when 'azure'
  39. :microsoft
  40. else
  41. :local
  42. end
  43. end
  44. def active_storage_config
  45. case provider_type
  46. when 'local'
  47. {
  48. service: :local,
  49. root: config['local_path'] || Rails.root.join('storage')
  50. }
  51. when 's3'
  52. {
  53. service: :amazon,
  54. access_key_id: config['access_key_id'],
  55. secret_access_key: config['secret_access_key'],
  56. region: config['region'],
  57. bucket: config['bucket'],
  58. endpoint: config['endpoint']
  59. }.compact
  60. when 'gcs'
  61. {
  62. service: :google,
  63. project: config['project'],
  64. bucket: config['bucket'],
  65. credentials: config['credentials']
  66. }.compact
  67. when 'azure'
  68. {
  69. service: :microsoft,
  70. storage_account_name: config['storage_account_name'],
  71. storage_access_key: config['storage_access_key'],
  72. container: config['container']
  73. }.compact
  74. else
  75. { service: :local, root: Rails.root.join('storage') }
  76. end
  77. end
  78. private
  79. def set_default_position
  80. self.position ||= (StorageProvider.maximum(:position) || 0) + 1
  81. end
  82. def update_active_storage_config
  83. if active?
  84. # Deactivate other providers
  85. StorageProvider.where.not(id: id).update_all(active: false)
  86. # Update Rails storage configuration
  87. Rails.application.configure do
  88. config.active_storage.variant_processor = :mini_magick
  89. config.active_storage.service = name.underscore.to_sym
  90. config.active_storage.services[name.underscore.to_sym] = active_storage_config
  91. end
  92. end
  93. end
  94. end

app/models/subscriber.rb

0.0% lines covered

100.0% branches covered

165 relevant lines. 0 lines covered and 165 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Subscriber < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Versioning
  5. has_paper_trail
  6. # Serialization
  7. serialize :metadata, coder: JSON, type: Hash
  8. serialize :tags, coder: JSON, type: Array
  9. serialize :lists, coder: JSON, type: Array
  10. # Enums
  11. enum status: {
  12. pending: 0, # Awaiting confirmation
  13. confirmed: 1, # Confirmed and active
  14. unsubscribed: 2, # Opted out
  15. bounced: 3, # Email bounced
  16. complained: 4 # Marked as spam
  17. }, _suffix: true
  18. # Validations
  19. validates :email, presence: true,
  20. format: { with: URI::MailTo::EMAIL_REGEXP },
  21. uniqueness: { scope: :tenant_id, case_sensitive: false }
  22. validates :status, presence: true
  23. validate :email_not_in_blocklist
  24. # Scopes
  25. scope :confirmed, -> { where(status: 'confirmed') }
  26. scope :pending, -> { where(status: 'pending') }
  27. scope :unsubscribed, -> { where(status: 'unsubscribed') }
  28. scope :bounced, -> { where(status: 'bounced') }
  29. scope :complained, -> { where(status: 'complained') }
  30. scope :active, -> { where(status: ['confirmed', 'pending']) }
  31. scope :by_source, ->(source) { where(source: source) }
  32. scope :by_tag, ->(tag) { where("tags LIKE ?", "%#{tag}%") }
  33. scope :by_list, ->(list) { where("lists LIKE ?", "%#{list}%") }
  34. scope :recent, -> { order(created_at: :desc) }
  35. scope :search, ->(query) { where('email LIKE ? OR name LIKE ?', "%#{query}%", "%#{query}%") }
  36. # Callbacks
  37. before_create :generate_unsubscribe_token
  38. after_create :send_confirmation_email
  39. after_update :handle_status_change
  40. # Instance methods
  41. # Confirm subscription
  42. def confirm!
  43. update!(
  44. status: 'confirmed',
  45. confirmed_at: Time.current
  46. )
  47. end
  48. # Unsubscribe
  49. def unsubscribe!
  50. update!(
  51. status: 'unsubscribed',
  52. unsubscribed_at: Time.current
  53. )
  54. end
  55. # Resubscribe
  56. def resubscribe!
  57. update!(
  58. status: 'confirmed',
  59. unsubscribed_at: nil
  60. )
  61. end
  62. # Mark as bounced
  63. def mark_bounced!
  64. update!(status: 'bounced')
  65. end
  66. # Mark as complained (spam)
  67. def mark_complained!
  68. update!(status: 'complained')
  69. end
  70. # Add tag
  71. def add_tag(tag)
  72. self.tags ||= []
  73. self.tags << tag unless self.tags.include?(tag)
  74. save
  75. end
  76. # Remove tag
  77. def remove_tag(tag)
  78. self.tags ||= []
  79. self.tags.delete(tag)
  80. save
  81. end
  82. # Add to list
  83. def add_to_list(list)
  84. self.lists ||= []
  85. self.lists << list unless self.lists.include?(list)
  86. save
  87. end
  88. # Remove from list
  89. def remove_from_list(list)
  90. self.lists ||= []
  91. self.lists.delete(list)
  92. save
  93. end
  94. # Get unsubscribe URL
  95. def unsubscribe_url
  96. Rails.application.routes.url_helpers.unsubscribe_url(token: unsubscribe_token)
  97. rescue
  98. "#"
  99. end
  100. # Check if subscriber can receive emails
  101. def can_receive_emails?
  102. confirmed_status? && confirmed_at.present?
  103. end
  104. # Get metadata value
  105. def get_metadata(key, default = nil)
  106. (metadata || {})[key.to_s] || default
  107. end
  108. # Set metadata value
  109. def set_metadata(key, value)
  110. self.metadata ||= {}
  111. self.metadata[key.to_s] = value
  112. save
  113. end
  114. private
  115. def generate_unsubscribe_token
  116. self.unsubscribe_token ||= SecureRandom.urlsafe_base64(32)
  117. end
  118. def send_confirmation_email
  119. return if confirmed_at.present? # Already confirmed
  120. return unless Rails.env.production? || ENV['SEND_CONFIRMATION_EMAILS'] == 'true'
  121. # Send confirmation email via background job
  122. # SubscriberMailer.confirmation_email(self).deliver_later
  123. end
  124. def handle_status_change
  125. return unless saved_change_to_status?
  126. case status.to_sym
  127. when :confirmed
  128. self.confirmed_at ||= Time.current
  129. # Could trigger welcome email
  130. when :unsubscribed
  131. self.unsubscribed_at ||= Time.current
  132. # Could trigger goodbye email
  133. end
  134. end
  135. def email_not_in_blocklist
  136. # Check against a blocklist (could be a separate model or Redis set)
  137. blocklist = ['spam@example.com', 'abuse@example.com']
  138. if blocklist.include?(email&.downcase)
  139. errors.add(:email, 'is not allowed')
  140. end
  141. end
  142. # Class methods
  143. # Import subscribers from CSV
  144. def self.import_from_csv(csv_data)
  145. require 'csv'
  146. imported = 0
  147. errors = []
  148. CSV.parse(csv_data, headers: true).each_with_index do |row, index|
  149. subscriber = new(
  150. email: row['email'] || row['Email'],
  151. name: row['name'] || row['Name'],
  152. source: row['source'] || row['Source'] || 'csv_import',
  153. status: row['status'] || row['Status'] || 'confirmed'
  154. )
  155. if subscriber.save
  156. imported += 1
  157. else
  158. errors << { row: index + 2, email: row['email'], errors: subscriber.errors.full_messages }
  159. end
  160. end
  161. { imported: imported, errors: errors, total: imported + errors.count }
  162. end
  163. # Export to CSV
  164. def self.to_csv
  165. require 'csv'
  166. CSV.generate(headers: true) do |csv|
  167. csv << ['Email', 'Name', 'Status', 'Source', 'Confirmed At', 'Tags', 'Lists', 'Created At']
  168. all.each do |subscriber|
  169. csv << [
  170. subscriber.email,
  171. subscriber.name,
  172. subscriber.status,
  173. subscriber.source,
  174. subscriber.confirmed_at&.strftime('%Y-%m-%d %H:%M'),
  175. (subscriber.tags || []).join(', '),
  176. (subscriber.lists || []).join(', '),
  177. subscriber.created_at.strftime('%Y-%m-%d %H:%M')
  178. ]
  179. end
  180. end
  181. end
  182. # Get statistics
  183. def self.stats
  184. {
  185. total: count,
  186. confirmed: confirmed.count,
  187. pending: pending.count,
  188. unsubscribed: unsubscribed.count,
  189. bounced: bounced.count,
  190. growth_this_month: where('created_at >= ?', 1.month.ago).count,
  191. growth_this_week: where('created_at >= ?', 1.week.ago).count,
  192. confirmation_rate: count > 0 ? (confirmed.count.to_f / count * 100).round(1) : 0
  193. }
  194. end
  195. end

app/models/taxonomy.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Taxonomy < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. has_many :terms, dependent: :destroy
  6. # Serialization
  7. serialize :object_types, coder: JSON, type: Array
  8. serialize :settings, coder: JSON, type: Hash
  9. # Friendly ID for slugs
  10. extend FriendlyId
  11. friendly_id :name, use: :slugged
  12. # Validations
  13. validates :name, presence: true
  14. validates :slug, presence: true, uniqueness: true
  15. # Scopes
  16. scope :hierarchical, -> { where(hierarchical: true) }
  17. scope :flat, -> { where(hierarchical: false) }
  18. scope :for_posts, -> { where("object_types LIKE ?", "%Post%") }
  19. scope :for_pages, -> { where("object_types LIKE ?", "%Page%") }
  20. # Callbacks
  21. after_initialize :set_defaults, if: :new_record?
  22. # Methods
  23. def should_generate_new_friendly_id?
  24. name_changed? || slug.blank?
  25. end
  26. def root_terms
  27. terms.where(parent_id: nil).order(name: :asc)
  28. end
  29. def term_count
  30. terms.count
  31. end
  32. def applies_to?(object_type)
  33. object_types.include?(object_type.to_s)
  34. end
  35. # Built-in taxonomies
  36. def self.categories
  37. find_or_create_by!(slug: 'category') do |t|
  38. t.name = 'Categories'
  39. t.description = 'Post categories'
  40. t.hierarchical = true
  41. t.object_types = ['Post']
  42. end
  43. end
  44. def self.tags
  45. find_or_create_by!(slug: 'post_tag') do |t|
  46. t.name = 'Tags'
  47. t.description = 'Post tags'
  48. t.hierarchical = false
  49. t.object_types = ['Post']
  50. end
  51. end
  52. private
  53. def set_defaults
  54. self.hierarchical = false if hierarchical.nil?
  55. self.object_types ||= []
  56. self.settings ||= {}
  57. end
  58. end

app/models/template.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Template < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. belongs_to :theme
  5. # Template types
  6. TEMPLATE_TYPES = %w[
  7. homepage
  8. blog_index
  9. blog_single
  10. page_default
  11. page_full_width
  12. archive
  13. category
  14. tag
  15. search
  16. 404
  17. header
  18. footer
  19. sidebar
  20. ].freeze
  21. validates :name, presence: true
  22. validates :template_type, presence: true, inclusion: { in: TEMPLATE_TYPES }
  23. validates :template_type, uniqueness: { scope: :theme_id }
  24. # Scopes
  25. scope :active, -> { where(active: true) }
  26. scope :by_type, ->(type) { where(template_type: type) }
  27. # Callbacks
  28. after_initialize :set_defaults, if: :new_record?
  29. # Methods
  30. def render_content
  31. html_content || default_template
  32. end
  33. private
  34. def set_defaults
  35. self.active = true if active.nil?
  36. self.html_content ||= default_template
  37. self.css_content ||= default_css
  38. self.js_content ||= ''
  39. end
  40. def default_template
  41. <<-HTML
  42. <div class="container mx-auto px-4 py-8">
  43. <h1>Welcome to #{name}</h1>
  44. <p>Start customizing this template using the visual editor.</p>
  45. </div>
  46. HTML
  47. end
  48. def default_css
  49. <<-CSS
  50. body {
  51. font-family: system-ui, -apple-system, sans-serif;
  52. line-height: 1.6;
  53. color: #333;
  54. }
  55. .container {
  56. max-width: 1200px;
  57. }
  58. CSS
  59. end
  60. end

app/models/tenant.rb

54.43% lines covered

0.0% branches covered

79 relevant lines. 43 lines covered and 36 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. 1 class Tenant < ApplicationRecord
  2. # Serialization
  3. 1 serialize :settings, coder: JSON, type: Hash
  4. # Validations
  5. 1 validates :name, presence: true
  6. 1 validates :domain, uniqueness: true, allow_nil: true
  7. 1 validates :subdomain, uniqueness: true, allow_nil: true
  8. 1 validates :theme, presence: true
  9. 1 validates :storage_type, inclusion: { in: %w[local s3], message: "%{value} is not a valid storage type" }
  10. 1 validate :must_have_domain_or_subdomain
  11. # Associations
  12. 1 has_many :posts, dependent: :destroy
  13. 1 has_many :pages, dependent: :destroy
  14. 1 has_many :media, dependent: :destroy
  15. 1 has_many :comments, dependent: :destroy
  16. # Taxonomies instead of separate categories/tags
  17. 1 has_many :taxonomies, dependent: :destroy
  18. 1 has_many :terms, through: :taxonomies
  19. 1 has_many :menus, dependent: :destroy
  20. 1 has_many :widgets, dependent: :destroy
  21. 1 has_many :themes, dependent: :destroy
  22. 1 has_many :site_settings, dependent: :destroy
  23. 1 has_many :users, dependent: :nullify
  24. 1 has_many :email_logs
  25. # Scopes
  26. 1 scope :active, -> { where(active: true) }
  27. 1 scope :by_domain, ->(domain) { where(domain: domain) }
  28. 1 scope :by_subdomain, ->(subdomain) { where(subdomain: subdomain) }
  29. # Callbacks
  30. 1 after_initialize :set_defaults, if: :new_record?
  31. 1 after_create :create_default_settings
  32. # Class methods
  33. 1 def self.find_by_request(request)
  34. find_by(domain: request.host) || find_by(subdomain: request.subdomains.first)
  35. end
  36. 1 def self.current
  37. ActsAsTenant.current_tenant
  38. end
  39. # Instance methods
  40. 1 def activate!
  41. update!(active: true)
  42. end
  43. 1 def deactivate!
  44. update!(active: false)
  45. end
  46. 1 def full_url
  47. then: 0 if domain.present?
  48. else: 0 "https://#{domain}"
  49. then: 0 else: 0 elsif subdomain.present?
  50. "https://#{subdomain}.#{default_domain}"
  51. else
  52. nil
  53. end
  54. end
  55. 1 def default_domain
  56. ENV['APP_DOMAIN'] || 'railspress.app'
  57. end
  58. 1 def locale_list
  59. (locales || 'en').split(',').map(&:strip)
  60. end
  61. 1 def locale_list=(value)
  62. self.locales = Array(value).join(',')
  63. end
  64. # Storage methods
  65. 1 def using_s3?
  66. storage_type == 's3'
  67. end
  68. 1 def using_local_storage?
  69. storage_type == 'local'
  70. end
  71. 1 def storage_configured?
  72. then: 0 if using_s3?
  73. storage_bucket.present? && storage_region.present? &&
  74. storage_access_key.present? && storage_secret_key.present?
  75. else: 0 else
  76. true # Local storage is always configured
  77. end
  78. end
  79. 1 def storage_service
  80. then: 0 if using_s3?
  81. :amazon
  82. else: 0 else
  83. :local
  84. end
  85. end
  86. # Settings helpers
  87. 1 def get_setting(key, default = nil)
  88. then: 0 else: 0 settings&.dig(key) || default
  89. end
  90. 1 def set_setting(key, value)
  91. self.settings ||= {}
  92. self.settings[key] = value
  93. save
  94. end
  95. 1 private
  96. 1 def set_defaults
  97. self.theme ||= 'default'
  98. self.locales ||= 'en'
  99. then: 0 else: 0 self.active = true if self.active.nil?
  100. self.storage_type ||= 'local'
  101. self.settings ||= {}
  102. end
  103. 1 def must_have_domain_or_subdomain
  104. then: 0 else: 0 if domain.blank? && subdomain.blank?
  105. errors.add(:base, "Must have either a domain or subdomain")
  106. end
  107. end
  108. 1 def create_default_settings
  109. # Create default site settings for the tenant
  110. default_settings = {
  111. 'site_title' => name,
  112. 'site_tagline' => "Powered by #{name}",
  113. 'posts_per_page' => 10,
  114. 'default_post_status' => 'draft',
  115. 'comments_enabled' => true
  116. }
  117. default_settings.each do |key, value|
  118. site_settings.find_or_create_by!(key: key) do |setting|
  119. setting.value = value.to_s
  120. then: 0 else: 0 setting.setting_type = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? 'boolean' : 'string'
  121. setting.tenant = self
  122. end
  123. end
  124. end
  125. end

app/models/term.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Term < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant, optional: true)
  4. belongs_to :taxonomy
  5. belongs_to :parent, class_name: 'Term', optional: true
  6. # Associations
  7. has_many :children, class_name: 'Term', foreign_key: 'parent_id', dependent: :destroy
  8. has_many :term_relationships, dependent: :destroy
  9. # Serialization
  10. serialize :metadata, coder: JSON, type: Hash
  11. # Friendly ID for slugs
  12. extend FriendlyId
  13. friendly_id :name, use: [:slugged, :scoped], scope: :taxonomy
  14. # Validations
  15. validates :name, presence: true
  16. validates :slug, presence: true, uniqueness: { scope: :taxonomy_id }
  17. validates :taxonomy, presence: true
  18. # Scopes
  19. scope :root_terms, -> { where(parent_id: nil) }
  20. scope :ordered, -> { order(name: :asc) }
  21. scope :popular, -> { order(count: :desc) }
  22. scope :for_taxonomy, ->(taxonomy_slug) { joins(:taxonomy).where(taxonomies: { slug: taxonomy_slug }) }
  23. # Callbacks
  24. after_initialize :set_defaults, if: :new_record?
  25. after_save :update_count
  26. # Methods
  27. def should_generate_new_friendly_id?
  28. name_changed? || slug.blank?
  29. end
  30. def hierarchical?
  31. taxonomy&.hierarchical?
  32. end
  33. def update_count
  34. self.count = term_relationships.count
  35. save if count_changed?
  36. end
  37. def breadcrumbs
  38. result = [self]
  39. current = self
  40. while current.parent.present?
  41. result.unshift(current.parent)
  42. current = current.parent
  43. end
  44. result
  45. end
  46. # Get all objects (posts/pages) with this term
  47. def objects
  48. term_relationships.includes(:object).map(&:object).compact
  49. end
  50. # Get objects of specific type
  51. def objects_of_type(type)
  52. term_relationships.where(object_type: type).includes(:object).map(&:object).compact
  53. end
  54. # Get posts associated with this term
  55. def posts
  56. Post.joins(:term_relationships).where(term_relationships: { term_id: id })
  57. end
  58. # Convert Term to Liquid-compatible hash
  59. def to_liquid
  60. {
  61. 'id' => id,
  62. 'name' => name,
  63. 'slug' => slug,
  64. 'description' => description,
  65. 'count' => count,
  66. 'taxonomy' => taxonomy&.name,
  67. 'taxonomy_slug' => taxonomy&.slug,
  68. 'parent_id' => parent_id,
  69. 'children' => children.to_a, # Convert AssociationRelation to array
  70. 'metadata' => metadata || {}
  71. }
  72. end
  73. # Generate URL for the term
  74. def url
  75. # This would need to be implemented based on your routing
  76. "/#{taxonomy&.slug}/#{slug}"
  77. end
  78. # Make methods public for Liquid access
  79. public :url, :to_liquid
  80. private
  81. def set_defaults
  82. self.count ||= 0
  83. self.metadata ||= {}
  84. end
  85. end

app/models/term_relationship.rb

0.0% lines covered

100.0% branches covered

13 relevant lines. 0 lines covered and 13 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class TermRelationship < ApplicationRecord
  2. belongs_to :term, counter_cache: :count
  3. belongs_to :object, polymorphic: true
  4. validates :term, presence: true
  5. validates :object, presence: true
  6. validates :term_id, uniqueness: { scope: [:object_type, :object_id] }
  7. # Callbacks
  8. after_create :update_term_count
  9. after_destroy :update_term_count
  10. private
  11. def update_term_count
  12. term.update_count
  13. end
  14. end

app/models/theme.rb

41.54% lines covered

0.0% branches covered

65 relevant lines. 27 lines covered and 38 lines missed.
20 total branches, 0 branches covered and 20 branches missed.
    
  1. 1 class Theme < ApplicationRecord
  2. # Multi-tenancy
  3. 1 acts_as_tenant(:tenant)
  4. # Associations
  5. 1 has_many :templates, dependent: :destroy
  6. 1 has_many :theme_versions, foreign_key: :theme_name, primary_key: :name, dependent: :destroy
  7. 1 has_many :theme_files, foreign_key: :theme_name, primary_key: :name, dependent: :destroy
  8. # Serialization
  9. 1 serialize :config, coder: JSON, type: Hash
  10. # Validations
  11. 1 validates :name, presence: true, uniqueness: true
  12. 1 validates :slug, presence: true, uniqueness: true
  13. 1 validates :version, presence: true
  14. # Scopes
  15. 2 scope :active, -> { where(active: true) }
  16. # Callbacks
  17. 1 after_initialize :set_defaults, if: :new_record?
  18. 1 before_save :deactivate_others, if: :active?
  19. 1 before_save :set_slug_from_name
  20. # Methods
  21. 1 def self.current
  22. active.first || first
  23. end
  24. 1 def activate!
  25. Theme.where.not(id: id).update_all(active: false)
  26. success = update(active: true)
  27. if success
  28. then: 0 # Create PublishedThemeVersion if it doesn't exist
  29. ensure_published_version_exists!
  30. true
  31. else: 0 else
  32. false
  33. end
  34. end
  35. 1 def get_file(file_path)
  36. live_version = theme_versions.live.first
  37. then: 0 else: 0 live_version&.file_content(file_path)
  38. end
  39. 1 def get_parsed_file(file_path)
  40. content = get_file(file_path)
  41. else: 0 then: 0 return nil unless content
  42. then: 0 if file_path.end_with?('.json')
  43. JSON.parse(content)
  44. else: 0 else
  45. content
  46. end
  47. rescue JSON::ParserError
  48. nil
  49. end
  50. 1 def file_tree
  51. ThemesManager.new.file_tree(name)
  52. end
  53. 1 def live_version
  54. theme_versions.live.first
  55. end
  56. 1 def has_update_available?
  57. ThemesManager.new.check_for_updates(self)
  58. end
  59. 1 def get_template(template_type)
  60. templates.by_type(template_type).active.first
  61. end
  62. # Ensure a PublishedThemeVersion exists for this theme
  63. 1 def ensure_published_version_exists!
  64. # Check if we already have a PublishedThemeVersion for this theme
  65. then: 0 else: 0 return if PublishedThemeVersion.where(theme: self).exists?
  66. Rails.logger.info "Creating initial PublishedThemeVersion for theme: #{name}"
  67. # Create initial PublishedThemeVersion
  68. published_version = PublishedThemeVersion.create!(
  69. theme: self,
  70. version_number: 1,
  71. published_at: Time.current,
  72. published_by: User.first, # TODO: Use current user if available
  73. tenant: tenant
  74. )
  75. # Copy all files from this theme's version to PublishedThemeFile
  76. manager = ThemesManager.new
  77. theme_version = theme_versions.live.first
  78. then: 0 if theme_version
  79. theme_version.theme_files.each do |theme_file|
  80. # Convert absolute path to relative path
  81. relative_path = theme_file.file_path.gsub(/^.*\/themes\/[^\/]+\//, '')
  82. # Get content using relative path and theme name
  83. content = manager.get_file(relative_path, name)
  84. else: 0 then: 0 next unless content
  85. PublishedThemeFile.create!(
  86. published_theme_version: published_version,
  87. file_path: relative_path,
  88. file_type: theme_file.file_type,
  89. content: content,
  90. checksum: Digest::MD5.hexdigest(content)
  91. )
  92. end
  93. Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
  94. else: 0 else
  95. Rails.logger.warn "No theme version found for #{name}"
  96. end
  97. published_version
  98. end
  99. 1 def published_version
  100. PublishedThemeVersion.where(theme: self).first
  101. end
  102. 1 private
  103. 1 def set_defaults
  104. then: 0 else: 0 self.active = false if active.nil?
  105. self.config ||= {}
  106. end
  107. 1 def set_slug_from_name
  108. then: 0 else: 0 self.slug = name.parameterize if name.present? && slug.blank?
  109. end
  110. 1 def deactivate_others
  111. then: 0 else: 0 Theme.where.not(id: id).update_all(active: false) if active_changed? && active?
  112. end
  113. end

app/models/theme_file.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeFile < ApplicationRecord
  2. belongs_to :theme_version, optional: true
  3. has_many :theme_file_versions, dependent: :destroy
  4. # Validations
  5. validates :theme_name, presence: true
  6. validates :file_path, presence: true, uniqueness: { scope: [:theme_name, :theme_version_id] }
  7. validates :file_type, presence: true
  8. validates :current_checksum, presence: true
  9. # Scopes
  10. scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
  11. scope :templates, -> { where(file_type: 'template') }
  12. scope :sections, -> { where(file_type: 'section') }
  13. scope :layouts, -> { where(file_type: 'layout') }
  14. scope :assets, -> { where(file_type: 'asset') }
  15. scope :configs, -> { where(file_type: 'config') }
  16. # Methods
  17. def current_content
  18. latest_version&.content
  19. end
  20. def latest_version
  21. theme_file_versions.latest.first
  22. end
  23. def version_at(version_number)
  24. theme_file_versions.find_by(version_number: version_number)
  25. end
  26. def create_new_version(content, user, theme_version = nil)
  27. ThemeFileVersion.create_version(theme_name, file_path, content, user, theme_version)
  28. end
  29. def liquid_content?
  30. file_path.end_with?('.liquid')
  31. end
  32. def json_content?
  33. file_path.end_with?('.json')
  34. end
  35. def css_content?
  36. file_path.end_with?('.css')
  37. end
  38. def js_content?
  39. file_path.end_with?('.js')
  40. end
  41. def parsed_content
  42. return nil unless current_content
  43. if json_content?
  44. JSON.parse(current_content)
  45. elsif liquid_content?
  46. current_content
  47. else
  48. current_content
  49. end
  50. rescue JSON::ParserError
  51. nil
  52. end
  53. def parsed_schema
  54. return nil unless liquid_content? && current_content
  55. schema_match = current_content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
  56. return nil unless schema_match
  57. JSON.parse(schema_match[1])
  58. rescue JSON::ParserError
  59. nil
  60. end
  61. def self.find_or_create_from_path(theme_name, file_path)
  62. find_or_create_by(theme_name: theme_name, file_path: file_path) do |file|
  63. file.file_type = determine_file_type(file_path)
  64. end
  65. end
  66. private
  67. def self.determine_file_type(file_path)
  68. if file_path.start_with?('templates/')
  69. 'template'
  70. elsif file_path.start_with?('sections/')
  71. 'section'
  72. elsif file_path.start_with?('layout/')
  73. 'layout'
  74. elsif file_path.start_with?('assets/')
  75. 'asset'
  76. elsif file_path.start_with?('config/')
  77. 'config'
  78. else
  79. 'other'
  80. end
  81. end
  82. end

app/models/theme_file_version.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeFileVersion < ApplicationRecord
  2. belongs_to :user
  3. belongs_to :theme_file, optional: true
  4. belongs_to :theme_version, optional: true
  5. # Validations
  6. validates :version_number, presence: true, uniqueness: { scope: :theme_file_id }
  7. validates :file_checksum, presence: true
  8. # Scopes
  9. scope :latest, -> { order(version_number: :desc) }
  10. # Callbacks
  11. before_create :set_version_number
  12. after_create :update_theme_file_version
  13. # Methods
  14. def self.create_version(theme_file, content, user, theme_version = nil)
  15. create!(
  16. theme_file: theme_file,
  17. content: content,
  18. file_size: content.bytesize,
  19. user: user,
  20. theme_version: theme_version,
  21. change_summary: "Version #{version_number}"
  22. )
  23. end
  24. private
  25. def set_version_number
  26. latest = self.class.where(theme_file: theme_file).latest.first
  27. self.version_number = latest ? latest.version_number + 1 : 1
  28. end
  29. def update_theme_file_version
  30. theme_file.update!(current_version: version_number)
  31. end
  32. def determine_file_type(file_path)
  33. if file_path.start_with?('templates/')
  34. 'template'
  35. elsif file_path.start_with?('sections/')
  36. 'section'
  37. elsif file_path.start_with?('layout/')
  38. 'layout'
  39. elsif file_path.start_with?('assets/')
  40. 'asset'
  41. elsif file_path.start_with?('config/')
  42. 'config'
  43. else
  44. 'other'
  45. end
  46. end
  47. end

app/models/theme_preview.rb

0.0% lines covered

100.0% branches covered

94 relevant lines. 0 lines covered and 94 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemePreview < ApplicationRecord
  2. belongs_to :builder_theme
  3. belongs_to :tenant
  4. has_many :theme_preview_sections, dependent: :destroy
  5. has_many :theme_preview_files, dependent: :destroy
  6. validates :template_name, presence: true, uniqueness: { scope: :builder_theme_id }
  7. # Initialize preview with files and sections from builder theme
  8. def initialize_from_builder_theme!
  9. # Copy files from builder theme
  10. ThemePreviewFile.copy_from_builder_theme(builder_theme, template_name)
  11. # Copy sections from builder theme
  12. ThemePreviewSection.copy_from_builder_theme(self, builder_theme, template_name)
  13. end
  14. # Update section settings
  15. def update_section_settings(section_id, settings)
  16. section = theme_preview_sections.find_by(section_id: section_id)
  17. if section
  18. # Update existing section
  19. section.update_settings(settings)
  20. Rails.logger.info "Updated existing section #{section_id} with settings: #{settings.inspect}"
  21. else
  22. # Create new section if it doesn't exist
  23. new_section = theme_preview_sections.create!(
  24. section_id: section_id,
  25. section_type: section_id, # Default type
  26. settings: settings,
  27. position: theme_preview_sections.count
  28. )
  29. Rails.logger.info "Created new section #{section_id} with settings: #{settings.inspect}"
  30. new_section
  31. end
  32. end
  33. # Update section order
  34. def update_section_order(section_ids)
  35. ThemePreviewSection.reorder_sections(self, section_ids)
  36. end
  37. # Get sections ordered by position
  38. def ordered_sections
  39. theme_preview_sections.ordered_by_position(self)
  40. end
  41. # Get template content as JSON (for compatibility)
  42. def template_content
  43. sections_data = {}
  44. section_order = []
  45. ordered_sections.each do |section|
  46. section_data = {
  47. 'type' => section.section_type,
  48. 'settings' => section.settings
  49. }
  50. # Add blocks if the section has them
  51. if section.blocks.any?
  52. section_data['blocks'] = section.blocks.map do |block|
  53. {
  54. 'id' => block.block_id,
  55. 'type' => block.block_type,
  56. 'settings' => block.settings
  57. }
  58. end
  59. end
  60. sections_data[section.section_id] = section_data
  61. section_order << section.section_id
  62. end
  63. {
  64. 'name' => template_name.humanize,
  65. 'sections' => sections_data,
  66. 'order' => section_order,
  67. 'theme_settings' => self.theme_settings_json || {}
  68. }
  69. end
  70. # Class methods for managing previews
  71. def self.find_or_create_for_builder(builder_theme, template_name = 'index')
  72. preview = find_by(
  73. builder_theme: builder_theme,
  74. template_name: template_name
  75. )
  76. unless preview
  77. preview = create!(
  78. builder_theme: builder_theme,
  79. tenant: builder_theme.tenant,
  80. template_name: template_name
  81. )
  82. # Initialize with files and sections from builder theme
  83. preview.initialize_from_builder_theme!
  84. else
  85. # Ensure existing preview has sections (in case it was created before sections were added)
  86. if preview.theme_preview_sections.empty?
  87. Rails.logger.info "Initializing empty ThemePreview sections from BuilderTheme"
  88. preview.initialize_from_builder_theme!
  89. end
  90. end
  91. preview
  92. end
  93. # Clean up duplicate sections
  94. def cleanup_duplicates!
  95. Rails.logger.info "=== CLEANING UP DUPLICATE SECTIONS ==="
  96. # Remove duplicate sections (keep the latest one)
  97. section_ids = theme_preview_sections.pluck(:section_id)
  98. duplicate_section_ids = section_ids.select { |id| section_ids.count(id) > 1 }.uniq
  99. Rails.logger.info "Found duplicate section IDs: #{duplicate_section_ids}"
  100. duplicate_section_ids.each do |section_id|
  101. duplicates = theme_preview_sections.where(section_id: section_id).order(:updated_at)
  102. Rails.logger.info "Cleaning up #{duplicates.count} duplicates for section #{section_id}"
  103. # Keep the latest one, remove the rest
  104. duplicates.offset(1).destroy_all
  105. end
  106. # Note: ThemePreviewFile belongs to BuilderTheme, not ThemePreview
  107. # So we don't need to clean up files here
  108. end
  109. # Class method to clean up all duplicates across all previews
  110. def self.cleanup_all_duplicates!
  111. all.each(&:cleanup_duplicates!)
  112. end
  113. end

app/models/theme_preview_block.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemePreviewBlock < ApplicationRecord
  2. belongs_to :theme_preview_section
  3. validates :block_id, presence: true, uniqueness: { scope: :theme_preview_section_id }
  4. validates :block_type, presence: true
  5. validates :position, presence: true
  6. serialize :settings, coder: JSON, type: Hash
  7. # Ensure settings is never nil to satisfy the NOT NULL constraint
  8. before_validation :ensure_settings_not_nil
  9. private
  10. def ensure_settings_not_nil
  11. # Ensure settings is never nil or empty to satisfy the NOT NULL constraint
  12. # The JSON serializer converts empty hashes to nil, so we need a non-empty hash
  13. if settings.nil? || settings.empty?
  14. self.settings = { 'initialized' => true }
  15. end
  16. end
  17. end

app/models/theme_preview_file.rb

0.0% lines covered

100.0% branches covered

65 relevant lines. 0 lines covered and 65 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemePreviewFile < ApplicationRecord
  2. belongs_to :builder_theme
  3. belongs_to :tenant
  4. validates :file_path, presence: true, uniqueness: { scope: :builder_theme_id }
  5. validates :file_type, presence: true
  6. validates :content, presence: true
  7. # Class method to copy files from BuilderTheme to ThemePreview
  8. def self.copy_from_builder_theme(builder_theme, template_name)
  9. # Get all files from the published theme (since BuilderTheme files might be empty)
  10. published_version = builder_theme.published_version
  11. published_files = published_version.published_theme_files
  12. published_file_paths = published_files.pluck(:file_path)
  13. # Get existing preview files
  14. existing_preview_files = where(builder_theme: builder_theme).index_by(&:file_path)
  15. # Track which files we've processed
  16. processed_file_paths = []
  17. published_files.each do |published_file|
  18. processed_file_paths << published_file.file_path
  19. if existing_preview_files[published_file.file_path]
  20. # Update existing preview file if content has changed
  21. existing_file = existing_preview_files[published_file.file_path]
  22. if existing_file.content != published_file.content
  23. existing_file.update!(
  24. content: published_file.content,
  25. file_type: published_file.file_type
  26. )
  27. end
  28. else
  29. # Create new preview file
  30. create!(
  31. builder_theme: builder_theme,
  32. tenant: builder_theme.tenant,
  33. file_path: published_file.file_path,
  34. file_type: published_file.file_type,
  35. content: published_file.content
  36. )
  37. end
  38. end
  39. # Remove preview files that no longer exist in the published theme
  40. files_to_remove = existing_preview_files.keys - processed_file_paths
  41. files_to_remove.each do |file_path|
  42. existing_preview_files[file_path]&.destroy!
  43. end
  44. end
  45. # Get template file content for a specific template
  46. def self.get_template_content(builder_theme, template_name)
  47. template_file = find_by(
  48. builder_theme: builder_theme,
  49. file_path: "templates/#{template_name}.json"
  50. )
  51. if template_file
  52. JSON.parse(template_file.content)
  53. else
  54. # Return empty structure if no template exists
  55. {
  56. 'name' => template_name.humanize,
  57. 'sections' => {},
  58. 'order' => []
  59. }
  60. end
  61. end
  62. # Update template file content
  63. def self.update_template_content(builder_theme, template_name, content)
  64. template_file = find_or_create_by(
  65. builder_theme: builder_theme,
  66. file_path: "templates/#{template_name}.json",
  67. file_type: 'template'
  68. ) do |file|
  69. file.tenant = builder_theme.tenant
  70. file.content = content.to_json
  71. end
  72. template_file.update!(content: content.to_json)
  73. template_file
  74. end
  75. end

app/models/theme_preview_section.rb

0.0% lines covered

100.0% branches covered

68 relevant lines. 0 lines covered and 68 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemePreviewSection < ApplicationRecord
  2. belongs_to :theme_preview
  3. has_many :theme_preview_blocks, dependent: :destroy
  4. validates :section_id, presence: true, uniqueness: { scope: :theme_preview_id }
  5. validates :section_type, presence: true
  6. validates :position, presence: true
  7. serialize :settings, coder: JSON, type: Hash
  8. # Ensure settings is never nil to satisfy the NOT NULL constraint
  9. before_validation :ensure_settings_not_nil
  10. # Get blocks ordered by position
  11. def blocks
  12. theme_preview_blocks.order(:position)
  13. end
  14. # Class method to copy sections from BuilderTheme to ThemePreview
  15. def self.copy_from_builder_theme(theme_preview, builder_theme, template_name)
  16. # Get the template file from published theme files (since BuilderTheme files might be empty)
  17. published_version = builder_theme.published_version
  18. template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
  19. if template_file
  20. template_content = JSON.parse(template_file.content)
  21. sections = template_content['sections'] || {}
  22. section_order = (template_content['order'] || sections.keys).uniq # Remove duplicates
  23. # Get existing sections in preview
  24. existing_sections = where(theme_preview: theme_preview).index_by(&:section_id)
  25. # Track which sections we've processed
  26. processed_section_ids = []
  27. # Create or update sections in the preview
  28. section_order.each_with_index do |section_id, index|
  29. section_data = sections[section_id]
  30. next unless section_data
  31. processed_section_ids << section_id
  32. if existing_sections[section_id]
  33. # Update existing section
  34. existing_sections[section_id].update!(
  35. section_type: section_data['type'] || section_id,
  36. settings: section_data['settings'] || {},
  37. position: index
  38. )
  39. else
  40. # Create new section
  41. create!(
  42. theme_preview: theme_preview,
  43. section_id: section_id,
  44. section_type: section_data['type'] || section_id,
  45. settings: section_data['settings'] || {},
  46. position: index
  47. )
  48. end
  49. end
  50. # Remove sections that no longer exist in the builder theme
  51. sections_to_remove = existing_sections.keys - processed_section_ids
  52. sections_to_remove.each do |section_id|
  53. existing_sections[section_id]&.destroy!
  54. end
  55. else
  56. # If no template file exists, clear all existing sections
  57. where(theme_preview: theme_preview).destroy_all
  58. end
  59. end
  60. # Update section settings
  61. def update_settings(new_settings)
  62. self.settings = new_settings
  63. save!
  64. end
  65. # Reorder sections
  66. def self.reorder_sections(theme_preview, section_ids)
  67. section_ids.each_with_index do |section_id, index|
  68. section = find_by(theme_preview: theme_preview, section_id: section_id)
  69. section&.update!(position: index)
  70. end
  71. end
  72. # Get sections ordered by position
  73. def self.ordered_by_position(theme_preview)
  74. where(theme_preview: theme_preview).order(:position)
  75. end
  76. private
  77. def ensure_settings_not_nil
  78. # Ensure settings is never nil or empty to satisfy the NOT NULL constraint
  79. # The JSON serializer converts empty hashes to nil, so we need a non-empty hash
  80. if settings.nil? || settings.empty?
  81. self.settings = { 'initialized' => true }
  82. end
  83. end
  84. end

app/models/theme_version.rb

0.0% lines covered

100.0% branches covered

103 relevant lines. 0 lines covered and 103 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeVersion < ApplicationRecord
  2. belongs_to :user
  3. has_many :theme_file_versions, dependent: :nullify
  4. has_many :theme_files, dependent: :destroy
  5. # Validations
  6. validates :theme_name, presence: true
  7. validates :version, presence: true
  8. validates :user_id, presence: true
  9. # Scopes
  10. scope :live, -> { where(is_live: true) }
  11. scope :preview, -> { where(is_preview: true) }
  12. scope :published, -> { where.not(published_at: nil) }
  13. scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
  14. # Callbacks
  15. before_create :generate_version_number
  16. after_create :snapshot_theme_files
  17. # Methods
  18. def self.create_preview(theme_name, user, changes = {})
  19. create!(
  20. theme_name: theme_name,
  21. user: user,
  22. is_preview: true,
  23. is_live: false,
  24. change_summary: changes[:summary] || "Preview version"
  25. )
  26. end
  27. def self.create_live_version(theme_name, user, changes = {})
  28. # Deactivate current live version
  29. live.for_theme(theme_name).update_all(is_live: false)
  30. create!(
  31. theme_name: theme_name,
  32. user: user,
  33. is_preview: false,
  34. is_live: true,
  35. published_at: Time.current,
  36. change_summary: changes[:summary] || "Published version"
  37. )
  38. end
  39. def publish!
  40. # Deactivate current live version
  41. self.class.live.for_theme(theme_name).update_all(is_live: false)
  42. # Make this version live
  43. update!(
  44. is_live: true,
  45. is_preview: false,
  46. published_at: Time.current
  47. )
  48. end
  49. def file_content(file_path)
  50. # Try exact match first (for full paths)
  51. theme_file = theme_files.find_by(file_path: file_path)
  52. return theme_file.theme_file_versions.latest.first&.content if theme_file
  53. # Try to find by matching the end of the path (for legacy relative paths)
  54. theme_file = theme_files.find { |file| file.file_path.end_with?("/#{file_path}") }
  55. return nil unless theme_file
  56. theme_file.theme_file_versions.latest.first&.content
  57. end
  58. def template_data(template_type)
  59. # Build full path for template file - use lowercase theme name for filesystem
  60. theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
  61. full_path = File.join(theme_path, "templates/#{template_type}.json")
  62. content = file_content(full_path)
  63. content ? JSON.parse(content) : {}
  64. rescue JSON::ParserError
  65. {}
  66. end
  67. def section_content(section_type)
  68. # Build full path for section file - use lowercase theme name for filesystem
  69. theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
  70. full_path = File.join(theme_path, "sections/#{section_type}.liquid")
  71. file_content(full_path) || ''
  72. end
  73. def layout_content
  74. # Build full path for layout file - use lowercase theme name for filesystem
  75. theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
  76. full_path = File.join(theme_path, "layout/theme.liquid")
  77. file_content(full_path) || ''
  78. end
  79. def assets
  80. # Build full paths for asset files - use lowercase theme name for filesystem
  81. theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
  82. {
  83. css: file_content(File.join(theme_path, "assets/theme.css")) || '',
  84. js: file_content(File.join(theme_path, "assets/theme.js")) || ''
  85. }
  86. end
  87. def theme_files
  88. ThemeFile.where(theme_version: self)
  89. end
  90. def templates
  91. theme_file_versions.joins(:theme_file).merge(ThemeFile.templates)
  92. end
  93. def sections
  94. theme_file_versions.joins(:theme_file).merge(ThemeFile.sections)
  95. end
  96. def layouts
  97. theme_file_versions.joins(:theme_file).merge(ThemeFile.layouts)
  98. end
  99. def assets_files
  100. theme_file_versions.joins(:theme_file).merge(ThemeFile.assets)
  101. end
  102. private
  103. def generate_version_number
  104. last_version = self.class.for_theme(theme_name).order(:created_at).last
  105. if last_version
  106. version_parts = last_version.version.split('.')
  107. version_parts[2] = (version_parts[2].to_i + 1).to_s
  108. self.version = version_parts.join('.')
  109. else
  110. self.version = "1.0.0"
  111. end
  112. end
  113. def snapshot_theme_files
  114. ThemeVersionService.new(self).snapshot_theme_files
  115. end
  116. end

app/models/theme_version_file.rb

0.0% lines covered

100.0% branches covered

62 relevant lines. 0 lines covered and 62 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeVersionFile < ApplicationRecord
  2. belongs_to :theme_version
  3. # Validations
  4. validates :file_path, presence: true
  5. validates :file_type, presence: true
  6. # Scopes
  7. scope :templates, -> { where(file_type: 'template') }
  8. scope :sections, -> { where(file_type: 'section') }
  9. scope :layouts, -> { where(file_type: 'layout') }
  10. scope :assets, -> { where(file_type: 'asset') }
  11. scope :configs, -> { where(file_type: 'config') }
  12. # Methods
  13. def self.create_from_file(theme_version, file_path, content)
  14. file_type = determine_file_type(file_path)
  15. create!(
  16. theme_version: theme_version,
  17. file_path: file_path,
  18. file_type: file_type,
  19. content: content,
  20. file_size: content.bytesize
  21. )
  22. end
  23. def liquid_content?
  24. file_path.end_with?('.liquid')
  25. end
  26. def json_content?
  27. file_path.end_with?('.json')
  28. end
  29. def css_content?
  30. file_path.end_with?('.css')
  31. end
  32. def js_content?
  33. file_path.end_with?('.js')
  34. end
  35. def parsed_json
  36. return nil unless json_content?
  37. JSON.parse(content)
  38. rescue JSON::ParserError
  39. nil
  40. end
  41. def parsed_schema
  42. return nil unless liquid_content?
  43. # Extract schema from liquid content
  44. schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
  45. return nil unless schema_match
  46. JSON.parse(schema_match[1])
  47. rescue JSON::ParserError
  48. nil
  49. end
  50. private
  51. def self.determine_file_type(file_path)
  52. if file_path.start_with?('templates/')
  53. 'template'
  54. elsif file_path.start_with?('sections/')
  55. 'section'
  56. elsif file_path.start_with?('layout/')
  57. 'layout'
  58. elsif file_path.start_with?('assets/')
  59. 'asset'
  60. elsif file_path.start_with?('config/')
  61. 'config'
  62. else
  63. 'other'
  64. end
  65. end
  66. end

app/models/trash_setting.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class TrashSetting < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Validations
  5. validates :cleanup_after_days, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 365 }
  6. # Callbacks
  7. before_validation :set_defaults
  8. # Class methods
  9. def self.current
  10. find_by(tenant: ActsAsTenant.current_tenant) || create_default!
  11. end
  12. def self.create_default!
  13. create!(
  14. auto_cleanup_enabled: true,
  15. cleanup_after_days: 30,
  16. tenant: ActsAsTenant.current_tenant
  17. )
  18. end
  19. # Instance methods
  20. def cleanup_after_hours
  21. cleanup_after_days * 24
  22. end
  23. def cleanup_after_minutes
  24. cleanup_after_hours * 60
  25. end
  26. def cleanup_threshold
  27. cleanup_after_days.days.ago
  28. end
  29. def should_cleanup?(deleted_at)
  30. return false unless auto_cleanup_enabled?
  31. deleted_at < cleanup_threshold
  32. end
  33. private
  34. def set_defaults
  35. self.auto_cleanup_enabled = true if auto_cleanup_enabled.nil?
  36. self.cleanup_after_days ||= 30
  37. end
  38. end

app/models/upload.rb

0.0% lines covered

100.0% branches covered

157 relevant lines. 0 lines covered and 157 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Upload < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. belongs_to :user
  5. belongs_to :storage_provider, optional: true
  6. # ActiveStorage for file attachment
  7. has_one_attached :file
  8. # Serialization
  9. serialize :variants, coder: JSON, type: Hash
  10. # Relationships
  11. has_many :media, dependent: :destroy
  12. # Validations
  13. validates :title, presence: true
  14. validates :file, presence: true
  15. # Scopes
  16. scope :quarantined, -> { where(quarantined: true) }
  17. scope :approved, -> { where(quarantined: [false, nil]) }
  18. # Callbacks
  19. after_commit :trigger_upload_hooks, on: [:create, :update], if: -> { file.attached? }
  20. before_validation :configure_storage, on: :create
  21. # Scopes
  22. scope :images, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] }) }
  23. scope :videos, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['video/mp4', 'video/webm'] }) }
  24. scope :documents, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['application/pdf', 'application/msword'] }) }
  25. scope :recent, -> { order(created_at: :desc) }
  26. # Methods
  27. def image?
  28. file.attached? && file.content_type&.start_with?('image/')
  29. end
  30. def video?
  31. file.attached? && file.content_type&.start_with?('video/')
  32. end
  33. def document?
  34. file.attached? && file.content_type&.start_with?('application/')
  35. end
  36. def file_size
  37. file.attached? ? file.byte_size : 0
  38. end
  39. def content_type
  40. file.attached? ? file.content_type : nil
  41. end
  42. def filename
  43. file.attached? ? file.filename.to_s : nil
  44. end
  45. def url
  46. return nil unless file.attached?
  47. # Check if CDN is enabled
  48. storage_config = StorageConfigurationService.new
  49. if storage_config.cdn_enabled?
  50. # Return CDN URL
  51. cdn_base = storage_config.cdn_url.chomp('/')
  52. "#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)}"
  53. else
  54. # Return regular Rails blob path
  55. Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
  56. end
  57. end
  58. def quarantined?
  59. quarantined == true
  60. end
  61. def approved?
  62. !quarantined?
  63. end
  64. def approve!
  65. update!(quarantined: false, quarantine_reason: nil)
  66. end
  67. def reject!
  68. destroy!
  69. end
  70. # Variant methods
  71. def has_variant?(format)
  72. variants&.key?(format.to_s)
  73. end
  74. def variant_url(format)
  75. return nil unless has_variant?(format)
  76. blob_id = variants[format.to_s]['blob_id']
  77. blob = ActiveStorage::Blob.find_by(id: blob_id)
  78. return nil unless blob
  79. storage_config = StorageConfigurationService.new
  80. if storage_config.cdn_enabled?
  81. cdn_base = storage_config.cdn_url.chomp('/')
  82. "#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)}"
  83. else
  84. Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
  85. end
  86. end
  87. def webp_url
  88. variant_url('webp')
  89. end
  90. def avif_url
  91. variant_url('avif')
  92. end
  93. def optimized_url
  94. # Return the best available format based on browser support
  95. # This would typically be handled by a helper or view
  96. avif_url || webp_url || url
  97. end
  98. # Responsive variant methods
  99. def responsive_variant_url(format, width)
  100. return nil unless variants&.dig("#{format}_#{width}w")
  101. blob_id = variants["#{format}_#{width}w"]['blob_id']
  102. blob = ActiveStorage::Blob.find_by(id: blob_id)
  103. return nil unless blob
  104. storage_config = StorageConfigurationService.new
  105. if storage_config.cdn_enabled?
  106. cdn_base = storage_config.cdn_url.chomp('/')
  107. "#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)}"
  108. else
  109. Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
  110. end
  111. end
  112. def responsive_webp_url(width)
  113. responsive_variant_url('webp', width)
  114. end
  115. def responsive_avif_url(width)
  116. responsive_variant_url('avif', width)
  117. end
  118. def responsive_original_url(width)
  119. responsive_variant_url('original', width)
  120. end
  121. # Generate srcset for responsive images
  122. def generate_srcset(format = 'auto', breakpoints = [320, 640, 768, 1024, 1200, 1920])
  123. srcset_parts = []
  124. breakpoints.each do |width|
  125. url = case format
  126. when 'avif'
  127. responsive_avif_url(width) || avif_url
  128. when 'webp'
  129. responsive_webp_url(width) || webp_url
  130. when 'original'
  131. responsive_original_url(width) || url
  132. else # auto
  133. responsive_avif_url(width) || responsive_webp_url(width) || responsive_original_url(width) || url
  134. end
  135. srcset_parts << "#{url} #{width}w" if url
  136. end
  137. srcset_parts.join(', ')
  138. end
  139. # Get available responsive variants
  140. def available_responsive_variants
  141. return {} unless variants
  142. variants.select { |key, _| key.include?('_') && key.include?('w') }
  143. end
  144. # Core image optimization method for uploads
  145. def optimize_image_if_needed
  146. return unless image?
  147. return unless file&.attached?
  148. # Check if optimization is enabled in settings
  149. storage_config = StorageConfigurationService.new
  150. return unless storage_config.auto_optimize_enabled?
  151. # Check media settings
  152. return unless SiteSetting.get('auto_optimize_images', false)
  153. # Find associated medium or create one for optimization
  154. medium = media.first
  155. if medium
  156. # Use existing medium
  157. OptimizeImageJob.perform_later(medium_id: medium.id)
  158. else
  159. # Create a temporary medium for optimization
  160. temp_medium = Medium.create!(
  161. title: title,
  162. description: description,
  163. alt_text: alt_text,
  164. user: user,
  165. upload: self
  166. )
  167. OptimizeImageJob.perform_later(medium_id: temp_medium.id)
  168. end
  169. Rails.logger.info "Queued image optimization for upload #{id} (core system)"
  170. end
  171. def trigger_upload_hooks
  172. Railspress::PluginSystem.do_action('upload_created', self) if saved_change_to_id?
  173. Railspress::PluginSystem.do_action('upload_updated', self)
  174. # Core image optimization for uploads (baked into system)
  175. optimize_image_if_needed if saved_change_to_id?
  176. end
  177. private
  178. def configure_storage
  179. # Configure storage based on current settings
  180. storage_config = StorageConfigurationService.new
  181. storage_config.configure_active_storage
  182. end
  183. end

app/models/upload_security.rb

0.0% lines covered

100.0% branches covered

156 relevant lines. 0 lines covered and 156 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class UploadSecurity < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Serialization
  5. serialize :allowed_extensions, JSON
  6. serialize :blocked_extensions, JSON
  7. serialize :allowed_mime_types, JSON
  8. serialize :blocked_mime_types, JSON
  9. # Validations
  10. validates :max_file_size, presence: true, numericality: { greater_than: 0 }
  11. # Callbacks
  12. before_validation :set_defaults
  13. after_update :update_global_settings
  14. # Default values
  15. DEFAULT_ALLOWED_EXTENSIONS = %w[jpg jpeg png gif webp pdf doc docx txt csv xlsx ppt pptx zip].freeze
  16. DEFAULT_BLOCKED_EXTENSIONS = %w[exe bat cmd sh php js html htm asp aspx jsp].freeze
  17. DEFAULT_ALLOWED_MIME_TYPES = %w[
  18. image/jpeg image/png image/gif image/webp
  19. application/pdf
  20. text/plain text/csv
  21. application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document
  22. application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
  23. application/vnd.ms-powerpoint application/vnd.openxmlformats-officedocument.presentationml.presentation
  24. application/zip
  25. ].freeze
  26. DEFAULT_BLOCKED_MIME_TYPES = %w[
  27. application/x-executable application/x-msdownload
  28. application/x-sh application/x-bat
  29. text/html text/javascript
  30. application/x-php application/x-asp
  31. ].freeze
  32. # Class methods
  33. def self.current
  34. find_by(tenant: ActsAsTenant.current_tenant) || create_default!
  35. end
  36. def self.create_default!
  37. create!(
  38. max_file_size: 10.megabytes,
  39. allowed_extensions: DEFAULT_ALLOWED_EXTENSIONS,
  40. blocked_extensions: DEFAULT_BLOCKED_EXTENSIONS,
  41. allowed_mime_types: DEFAULT_ALLOWED_MIME_TYPES,
  42. blocked_mime_types: DEFAULT_BLOCKED_MIME_TYPES,
  43. scan_for_viruses: false,
  44. quarantine_suspicious: true,
  45. auto_approve_trusted: false,
  46. tenant: ActsAsTenant.current_tenant
  47. )
  48. end
  49. # Instance methods
  50. def max_file_size_human
  51. ActiveSupport::NumberHelper.number_to_human_size(max_file_size)
  52. end
  53. def max_file_size_human=(value)
  54. self.max_file_size = parse_file_size(value)
  55. end
  56. def allowed_extensions_list
  57. Array(allowed_extensions).join(', ')
  58. end
  59. def allowed_extensions_list=(value)
  60. self.allowed_extensions = value.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
  61. end
  62. def blocked_extensions_list
  63. Array(blocked_extensions).join(', ')
  64. end
  65. def blocked_extensions_list=(value)
  66. self.blocked_extensions = value.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
  67. end
  68. def allowed_mime_types_list
  69. Array(allowed_mime_types).join(', ')
  70. end
  71. def allowed_mime_types_list=(value)
  72. self.allowed_mime_types = value.split(',').map(&:strip).reject(&:blank?)
  73. end
  74. def blocked_mime_types_list
  75. Array(blocked_mime_types).join(', ')
  76. end
  77. def blocked_mime_types_list=(value)
  78. self.blocked_mime_types = value.split(',').map(&:strip).reject(&:blank?)
  79. end
  80. # Security validation methods
  81. def file_allowed?(file)
  82. return false if file.nil?
  83. # Get storage settings for validation
  84. storage_settings = get_storage_settings
  85. # Check file size against storage settings first, then fallback to upload security
  86. max_size_from_storage = storage_settings[:max_file_size] * 1024 * 1024 # Convert MB to bytes
  87. effective_max_size = [max_file_size, max_size_from_storage].min
  88. return false if file.size > effective_max_size
  89. # Get file extension
  90. extension = File.extname(file.original_filename).downcase.gsub('.', '')
  91. # Check against storage settings allowed file types
  92. if storage_settings[:allowed_file_types].present?
  93. allowed_types = storage_settings[:allowed_file_types].split(',').map(&:strip).map(&:downcase)
  94. return false unless allowed_types.include?(extension)
  95. end
  96. # Check blocked extensions first (more restrictive)
  97. return false if Array(blocked_extensions).include?(extension)
  98. # Check allowed extensions if specified
  99. if allowed_extensions.present?
  100. return false unless Array(allowed_extensions).include?(extension)
  101. end
  102. # Check MIME type if available
  103. if file.content_type.present?
  104. # Check blocked MIME types first
  105. return false if Array(blocked_mime_types).include?(file.content_type)
  106. # Check allowed MIME types if specified
  107. if allowed_mime_types.present?
  108. return false unless Array(allowed_mime_types).include?(file.content_type)
  109. end
  110. end
  111. true
  112. end
  113. def file_suspicious?(file)
  114. return false unless quarantine_suspicious?
  115. # Check for suspicious patterns
  116. filename = file.original_filename.downcase
  117. # Double extensions (e.g., file.jpg.exe)
  118. return true if filename.match?(/\..*\..*\./)
  119. # Executable extensions disguised as images
  120. suspicious_patterns = [
  121. /\.(jpg|jpeg|png|gif)\.(exe|bat|cmd|sh)$/,
  122. /\.(pdf|doc)\.(exe|bat|cmd|sh)$/,
  123. /\.(zip|rar)\.(exe|bat|cmd|sh)$/
  124. ]
  125. return true if suspicious_patterns.any? { |pattern| filename.match?(pattern) }
  126. false
  127. end
  128. # Get current storage settings
  129. def get_storage_settings
  130. {
  131. max_file_size: SiteSetting.get('max_file_size', 10).to_i,
  132. allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3'),
  133. storage_type: SiteSetting.get('storage_type', 'local'),
  134. local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
  135. enable_cdn: SiteSetting.get('enable_cdn', false),
  136. cdn_url: SiteSetting.get('cdn_url', ''),
  137. auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true)
  138. }
  139. end
  140. private
  141. def set_defaults
  142. self.max_file_size ||= 10.megabytes
  143. self.allowed_extensions ||= DEFAULT_ALLOWED_EXTENSIONS
  144. self.blocked_extensions ||= DEFAULT_BLOCKED_EXTENSIONS
  145. self.allowed_mime_types ||= DEFAULT_ALLOWED_MIME_TYPES
  146. self.blocked_mime_types ||= DEFAULT_BLOCKED_MIME_TYPES
  147. self.scan_for_viruses = false if scan_for_viruses.nil?
  148. self.quarantine_suspicious = true if quarantine_suspicious.nil?
  149. self.auto_approve_trusted = false if auto_approve_trusted.nil?
  150. end
  151. def parse_file_size(value)
  152. case value.to_s.downcase
  153. when /(\d+)\s*mb?/
  154. $1.to_i.megabytes
  155. when /(\d+)\s*gb?/
  156. $1.to_i.gigabytes
  157. when /(\d+)\s*kb?/
  158. $1.to_i.kilobytes
  159. when /(\d+)\s*b?/
  160. $1.to_i.bytes
  161. else
  162. value.to_i
  163. end
  164. end
  165. def update_global_settings
  166. # Update global upload security settings
  167. Rails.application.config.upload_security = {
  168. max_file_size: max_file_size,
  169. allowed_extensions: allowed_extensions,
  170. blocked_extensions: blocked_extensions,
  171. allowed_mime_types: allowed_mime_types,
  172. blocked_mime_types: blocked_mime_types,
  173. scan_for_viruses: scan_for_viruses,
  174. quarantine_suspicious: quarantine_suspicious,
  175. auto_approve_trusted: auto_approve_trusted
  176. }
  177. end
  178. end

app/models/user.rb

51.0% lines covered

0.0% branches covered

100 relevant lines. 51 lines covered and 49 lines missed.
17 total branches, 0 branches covered and 17 branches missed.
    
  1. 1 class User < ApplicationRecord
  2. # Include default devise modules. Others available are:
  3. # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  4. 1 devise :database_authenticatable, :registerable,
  5. :recoverable, :rememberable, :validatable, :omniauthable,
  6. omniauth_providers: [:google_oauth2, :github, :facebook, :twitter]
  7. # WordPress-like roles
  8. 1 enum role: {
  9. subscriber: 0,
  10. contributor: 1,
  11. author: 2,
  12. editor: 3,
  13. administrator: 4
  14. }
  15. # Associations
  16. # ActiveStorage for avatar
  17. 1 has_one_attached :avatar
  18. # Multi-tenancy - users belong to tenants (many-to-one)
  19. 1 belongs_to :tenant, optional: true
  20. 1 has_many :posts, dependent: :destroy
  21. 1 has_many :pages, dependent: :destroy
  22. 1 has_many :media, dependent: :destroy
  23. 1 has_many :comments, dependent: :destroy
  24. 1 has_many :api_tokens, dependent: :destroy
  25. 1 has_many :ai_usages, dependent: :destroy
  26. 1 has_many :oauth_accounts, dependent: :destroy
  27. # GDPR-related associations
  28. 1 has_many :personal_data_export_requests, dependent: :destroy
  29. 1 has_many :personal_data_erasure_requests, dependent: :destroy
  30. 1 has_many :user_consents, dependent: :destroy
  31. # Meta fields for plugin extensibility
  32. 1 has_many :meta_fields, as: :metable, dependent: :destroy
  33. 1 include Metable
  34. # Editor preference
  35. 1 EDITOR_OPTIONS = %w[blocknote trix ckeditor editorjs].freeze
  36. 1 validates :editor_preference, inclusion: { in: EDITOR_OPTIONS }, allow_nil: true
  37. 1 def preferred_editor
  38. editor_preference.presence || 'blocknote' # Default to BlockNote
  39. end
  40. # Monaco Editor theme preference
  41. 1 MONACO_THEMES = %w[auto dark light blue].freeze
  42. 1 validates :monaco_theme, inclusion: { in: MONACO_THEMES }, allow_nil: true
  43. # API Key
  44. 1 validates :api_key, uniqueness: true, allow_nil: true
  45. 1 def preferred_monaco_theme
  46. monaco_theme.presence || 'auto' # Default to auto
  47. end
  48. # Sidebar order preference
  49. 1 def sidebar_order
  50. then: 0 if super.present?
  51. JSON.parse(super)
  52. else: 0 else
  53. ['publish', 'featured-image', 'categories-tags', 'excerpt', 'seo']
  54. end
  55. rescue JSON::ParserError
  56. ['publish', 'featured-image', 'categories-tags', 'excerpt', 'seo']
  57. end
  58. 1 def sidebar_order=(order)
  59. then: 0 else: 0 super(order.is_a?(Array) ? order.to_json : order)
  60. end
  61. # Validations
  62. 1 validates :role, presence: true
  63. # Callbacks
  64. 1 after_initialize :set_default_role, if: :new_record?
  65. 1 before_create :generate_api_token
  66. # Role helper methods
  67. 1 def admin?
  68. administrator?
  69. end
  70. 1 def can_publish?
  71. author? || editor? || administrator?
  72. end
  73. 1 def can_edit_others_posts?
  74. editor? || administrator?
  75. end
  76. 1 def can_delete_posts?
  77. administrator?
  78. end
  79. # API methods
  80. 1 def regenerate_api_token!
  81. update(api_token: generate_token)
  82. end
  83. 1 def rate_limit_exceeded?
  84. else: 0 then: 0 return false unless api_requests_reset_at
  85. then: 0 else: 0 if api_requests_reset_at < Time.current
  86. update(api_requests_count: 0, api_requests_reset_at: 1.hour.from_now)
  87. return false
  88. end
  89. (api_requests_count || 0) >= 1000 # 1000 requests per hour
  90. end
  91. 1 def increment_api_request!
  92. then: 0 else: 0 self.api_requests_reset_at = 1.hour.from_now if api_requests_reset_at.nil? || api_requests_reset_at < Time.current
  93. increment!(:api_requests_count)
  94. end
  95. # Admin bar permission checks
  96. 1 def can_manage_plugins?
  97. administrator? || role == 'editor'
  98. end
  99. 1 def can_manage_themes?
  100. administrator?
  101. end
  102. 1 def can_manage_settings?
  103. administrator?
  104. end
  105. 1 def can_manage_users?
  106. administrator?
  107. end
  108. 1 def can_create_posts?
  109. ['administrator', 'editor', 'author'].include?(role)
  110. end
  111. 1 def can_create_pages?
  112. ['administrator', 'editor'].include?(role)
  113. end
  114. 1 def can_upload_media?
  115. ['administrator', 'editor', 'author'].include?(role)
  116. end
  117. 1 def can_upload_files?
  118. ['administrator', 'editor', 'author'].include?(role)
  119. end
  120. # API Key methods
  121. 1 def generate_api_key
  122. loop do
  123. key = "sk-#{SecureRandom.hex(32)}"
  124. else: 0 then: 0 break key unless User.exists?(api_key: key)
  125. end
  126. end
  127. 1 def regenerate_api_key!
  128. self.api_key = generate_api_key
  129. save!
  130. end
  131. 1 private
  132. 1 def set_default_role
  133. self.role ||= :subscriber
  134. end
  135. 1 def generate_api_token
  136. self.api_token = generate_token
  137. self.api_key = generate_api_key
  138. self.api_requests_count = 0
  139. self.api_requests_reset_at = 1.hour.from_now
  140. end
  141. 1 def generate_token
  142. loop do
  143. token = SecureRandom.hex(32)
  144. else: 0 then: 0 break token unless User.exists?(api_token: token)
  145. end
  146. end
  147. 1 def create_user_tenant
  148. # Create a tenant for this user if they don't have one
  149. then: 0 else: 0 return if tenant_id.present?
  150. # Generate a unique subdomain based on email
  151. base_subdomain = email.split('@').first.gsub(/[^a-z0-9]/, '')
  152. subdomain = base_subdomain
  153. counter = 1
  154. # Ensure subdomain is unique
  155. body: 0 while Tenant.exists?(subdomain: subdomain)
  156. subdomain = "#{base_subdomain}#{counter}"
  157. counter += 1
  158. end
  159. # Create the tenant
  160. user_tenant = Tenant.create!(
  161. name: "#{email.split('@').first.humanize}'s Site",
  162. subdomain: subdomain,
  163. domain: nil, # Will be set later if needed
  164. theme: 'nordic',
  165. storage_type: 'local'
  166. )
  167. self.tenant = user_tenant
  168. end
  169. end

app/models/user_consent.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class UserConsent < ApplicationRecord
  2. acts_as_tenant(:tenant)
  3. belongs_to :user
  4. validates :consent_type, presence: true, uniqueness: { scope: :user_id }
  5. validates :consent_text, presence: true
  6. validates :ip_address, presence: true
  7. validates :user_agent, presence: true
  8. # Consent types
  9. CONSENT_TYPES = %w[
  10. data_processing
  11. marketing
  12. analytics
  13. cookies
  14. newsletter
  15. third_party_sharing
  16. ].freeze
  17. validates :consent_type, inclusion: { in: CONSENT_TYPES }
  18. scope :granted, -> { where(granted: true) }
  19. scope :withdrawn, -> { where(granted: false) }
  20. scope :by_type, ->(type) { where(consent_type: type) }
  21. scope :recent, -> { order(granted_at: :desc) }
  22. # Callbacks
  23. before_validation :set_defaults, on: :create
  24. def granted?
  25. granted && granted_at.present? && withdrawn_at.nil?
  26. end
  27. def withdrawn?
  28. !granted || withdrawn_at.present?
  29. end
  30. def withdraw!
  31. update!(
  32. granted: false,
  33. withdrawn_at: Time.current
  34. )
  35. end
  36. def grant!
  37. update!(
  38. granted: true,
  39. granted_at: Time.current,
  40. withdrawn_at: nil
  41. )
  42. end
  43. private
  44. def set_defaults
  45. self.granted_at ||= Time.current if granted
  46. end
  47. end

app/models/user_notification.rb

0.0% lines covered

100.0% branches covered

3 relevant lines. 0 lines covered and 3 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class UserNotification < ApplicationRecord
  2. belongs_to :user
  3. end

app/models/webhook.rb

0.0% lines covered

100.0% branches covered

73 relevant lines. 0 lines covered and 73 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Webhook < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Associations
  5. has_many :webhook_deliveries, dependent: :destroy
  6. # Serialization
  7. serialize :events, coder: JSON, type: Array
  8. # Validations
  9. validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
  10. validates :secret_key, presence: true
  11. validates :name, presence: true
  12. validates :events, presence: true
  13. validates :retry_limit, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }
  14. validates :timeout, numericality: { greater_than: 0, less_than_or_equal_to: 120 }
  15. # Callbacks
  16. before_validation :generate_secret_key, on: :create
  17. before_validation :set_defaults
  18. # Scopes
  19. scope :active, -> { where(active: true) }
  20. scope :for_event, ->(event_type) { where("events LIKE ?", "%#{event_type}%") }
  21. # Available webhook events
  22. AVAILABLE_EVENTS = [
  23. 'post.created',
  24. 'post.updated',
  25. 'post.published',
  26. 'post.deleted',
  27. 'page.created',
  28. 'page.updated',
  29. 'page.published',
  30. 'page.deleted',
  31. 'comment.created',
  32. 'comment.approved',
  33. 'comment.spam',
  34. 'user.created',
  35. 'user.updated',
  36. 'media.uploaded'
  37. ].freeze
  38. # Check if webhook is subscribed to an event
  39. def subscribed_to?(event_type)
  40. events.include?(event_type)
  41. end
  42. # Deliver a webhook
  43. def deliver(event_type, payload)
  44. return unless active? && subscribed_to?(event_type)
  45. delivery = webhook_deliveries.create!(
  46. event_type: event_type,
  47. payload: payload,
  48. status: 'pending',
  49. request_id: SecureRandom.uuid
  50. )
  51. # Enqueue for delivery
  52. DeliverWebhookJob.perform_later(delivery.id)
  53. delivery
  54. end
  55. # Generate HMAC signature for payload
  56. def sign_payload(payload_json)
  57. OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload_json)
  58. end
  59. # Update delivery statistics
  60. def record_delivery(success:)
  61. increment!(:total_deliveries)
  62. increment!(:failed_deliveries) unless success
  63. touch(:last_delivered_at) if success
  64. end
  65. # Check if webhook is healthy
  66. def healthy?
  67. return true if total_deliveries.zero?
  68. failure_rate = failed_deliveries.to_f / total_deliveries
  69. failure_rate < 0.5 # Less than 50% failure rate
  70. end
  71. # Calculate success rate percentage
  72. def success_rate
  73. return 100.0 if total_deliveries.zero?
  74. successful_deliveries = total_deliveries - failed_deliveries
  75. (successful_deliveries.to_f / total_deliveries * 100).round(1)
  76. end
  77. private
  78. def generate_secret_key
  79. self.secret_key ||= SecureRandom.hex(32)
  80. end
  81. def set_defaults
  82. self.retry_limit ||= 3
  83. self.timeout ||= 30
  84. self.total_deliveries ||= 0
  85. self.failed_deliveries ||= 0
  86. end
  87. end

app/models/webhook_delivery.rb

0.0% lines covered

100.0% branches covered

63 relevant lines. 0 lines covered and 63 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class WebhookDelivery < ApplicationRecord
  2. # Associations
  3. belongs_to :webhook
  4. # Validations
  5. validates :event_type, presence: true
  6. validates :status, presence: true, inclusion: { in: %w[pending success failed] }
  7. # Enums
  8. enum status: {
  9. pending: 'pending',
  10. success: 'success',
  11. failed: 'failed'
  12. }, _suffix: true
  13. # Scopes
  14. scope :recent, -> { order(created_at: :desc) }
  15. scope :failed, -> { where(status: 'failed') }
  16. scope :successful, -> { where(status: 'success') }
  17. scope :pending_retry, -> { where('status = ? AND retry_count < ? AND next_retry_at <= ?', 'failed', 3, Time.current) }
  18. # Callbacks
  19. after_create :schedule_delivery
  20. # Check if delivery can be retried
  21. def can_retry?
  22. failed_status? && retry_count < webhook.retry_limit
  23. end
  24. # Mark as successful
  25. def mark_success!(response_code, response_body)
  26. update!(
  27. status: 'success',
  28. response_code: response_code,
  29. response_body: response_body.to_s.truncate(5000),
  30. delivered_at: Time.current
  31. )
  32. webhook.record_delivery(success: true)
  33. end
  34. # Mark as failed
  35. def mark_failed!(error_message, response_code = nil, response_body = nil)
  36. update!(
  37. status: 'failed',
  38. error_message: error_message.to_s.truncate(1000),
  39. response_code: response_code,
  40. response_body: response_body.to_s.truncate(5000)
  41. )
  42. webhook.record_delivery(success: false)
  43. # Schedule retry if allowed
  44. schedule_retry if can_retry?
  45. end
  46. # Schedule retry with exponential backoff
  47. def schedule_retry
  48. increment!(:retry_count)
  49. # Exponential backoff: 1min, 5min, 15min
  50. delay = case retry_count
  51. when 1 then 1.minute
  52. when 2 then 5.minutes
  53. else 15.minutes
  54. end
  55. update!(next_retry_at: delay.from_now)
  56. # Schedule the retry job
  57. DeliverWebhookJob.set(wait: delay).perform_later(id)
  58. end
  59. # Get signed headers for delivery
  60. def signed_headers
  61. payload_json = payload.to_json
  62. signature = webhook.sign_payload(payload_json)
  63. {
  64. 'Content-Type' => 'application/json',
  65. 'User-Agent' => 'RailsPress-Webhooks/1.0',
  66. 'X-RailsPress-Event' => event_type,
  67. 'X-RailsPress-Delivery' => request_id,
  68. 'X-RailsPress-Signature' => signature,
  69. 'X-RailsPress-Signature-256' => "sha256=#{signature}"
  70. }
  71. end
  72. private
  73. def schedule_delivery
  74. DeliverWebhookJob.perform_later(id) if pending_status?
  75. end
  76. end

app/models/widget.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Widget < ApplicationRecord
  2. # Multi-tenancy
  3. acts_as_tenant(:tenant)
  4. # Serialization
  5. serialize :settings, coder: JSON, type: Hash
  6. # Validations
  7. validates :title, presence: true
  8. validates :widget_type, presence: true
  9. validates :sidebar_location, presence: true
  10. validates :position, presence: true, numericality: { only_integer: true }
  11. # Scopes
  12. scope :active, -> { where(active: true) }
  13. scope :by_location, ->(location) { where(sidebar_location: location) }
  14. scope :ordered, -> { order(position: :asc) }
  15. # Callbacks
  16. after_initialize :set_defaults, if: :new_record?
  17. # Widget types
  18. WIDGET_TYPES = %w[
  19. text
  20. recent_posts
  21. categories
  22. tags
  23. search
  24. custom_html
  25. recent_comments
  26. archives
  27. ].freeze
  28. validates :widget_type, inclusion: { in: WIDGET_TYPES }
  29. private
  30. def set_defaults
  31. self.active = true if active.nil?
  32. self.settings ||= {}
  33. self.position ||= (Widget.where(sidebar_location: sidebar_location).maximum(:position) || 0) + 1
  34. end
  35. end

app/policies/application_policy.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationPolicy
  3. attr_reader :user, :record
  4. def initialize(user, record)
  5. @user = user
  6. @record = record
  7. end
  8. def index?
  9. false
  10. end
  11. def show?
  12. false
  13. end
  14. def create?
  15. false
  16. end
  17. def new?
  18. create?
  19. end
  20. def update?
  21. false
  22. end
  23. def edit?
  24. update?
  25. end
  26. def destroy?
  27. false
  28. end
  29. class Scope
  30. def initialize(user, scope)
  31. @user = user
  32. @scope = scope
  33. end
  34. def resolve
  35. raise NoMethodError, "You must define #resolve in #{self.class}"
  36. end
  37. private
  38. attr_reader :user, :scope
  39. end
  40. end

app/services/advanced_analytics_service.rb

0.0% lines covered

100.0% branches covered

344 relevant lines. 0 lines covered and 344 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AdvancedAnalyticsService
  3. include Rails.application.routes.url_helpers
  4. # Advanced tracking capabilities like GA4/Matomo
  5. class << self
  6. # Track user journey and behavior patterns
  7. def track_user_journey(session_id, user_id = nil, event_data = {})
  8. return unless analytics_enabled?
  9. journey_data = {
  10. session_id: session_id,
  11. user_id: user_id,
  12. timestamp: Time.current,
  13. events: [],
  14. metadata: {}
  15. }.merge(event_data)
  16. # Store in Redis for real-time analysis
  17. Redis.current.setex("user_journey:#{session_id}", 30.minutes.to_i, journey_data.to_json)
  18. # Queue for background processing
  19. AnalyticsProcessingJob.perform_later('user_journey', journey_data)
  20. end
  21. # Track conversion funnels with attribution
  22. def track_conversion_funnel(funnel_id, step, session_id, user_id = nil, properties = {})
  23. return unless analytics_enabled?
  24. funnel_data = {
  25. funnel_id: funnel_id,
  26. step: step,
  27. session_id: session_id,
  28. user_id: user_id,
  29. timestamp: Time.current,
  30. properties: properties,
  31. attribution: get_attribution_data(session_id)
  32. }
  33. # Store conversion event
  34. AnalyticsEvent.track_conversion(
  35. event_name: "funnel_#{funnel_id}_#{step}",
  36. session_id: session_id,
  37. user_id: user_id,
  38. properties: funnel_data
  39. )
  40. # Update funnel progress
  41. update_funnel_progress(funnel_id, step, session_id)
  42. end
  43. # Track cohort analysis
  44. def track_user_cohort(user_id, cohort_type, properties = {})
  45. return unless analytics_enabled? && user_id.present?
  46. cohort_data = {
  47. user_id: user_id,
  48. cohort_type: cohort_type,
  49. timestamp: Time.current,
  50. properties: properties
  51. }
  52. # Store cohort membership
  53. Redis.current.sadd("cohort:#{cohort_type}:#{Date.current.strftime('%Y-%m')}", user_id)
  54. Redis.current.hset("user_cohort:#{user_id}", cohort_type, cohort_data.to_json)
  55. end
  56. # Track attribution and multi-touch attribution
  57. def track_attribution(session_id, touchpoint_type, touchpoint_data = {})
  58. return unless analytics_enabled?
  59. attribution_data = {
  60. session_id: session_id,
  61. touchpoint_type: touchpoint_type,
  62. timestamp: Time.current,
  63. data: touchpoint_data
  64. }
  65. # Store attribution chain
  66. Redis.current.lpush("attribution:#{session_id}", attribution_data.to_json)
  67. Redis.current.expire("attribution:#{session_id}", 30.days.to_i)
  68. end
  69. # Track custom dimensions and metrics
  70. def track_custom_dimension(session_id, dimension_name, dimension_value)
  71. return unless analytics_enabled?
  72. dimension_data = {
  73. session_id: session_id,
  74. dimension_name: dimension_name,
  75. dimension_value: dimension_value,
  76. timestamp: Time.current
  77. }
  78. # Store custom dimension
  79. Redis.current.hset("custom_dimensions:#{session_id}", dimension_name, dimension_value)
  80. Redis.current.expire("custom_dimensions:#{session_id}", 30.days.to_i)
  81. # Track as event
  82. AnalyticsEvent.track_conversion(
  83. event_name: 'custom_dimension',
  84. session_id: session_id,
  85. properties: dimension_data
  86. )
  87. end
  88. # Track advanced e-commerce events (for future WooCommerce-like plugins)
  89. def track_ecommerce_event(event_type, session_id, user_id = nil, ecommerce_data = {})
  90. return unless analytics_enabled?
  91. ecommerce_event = {
  92. event_type: event_type,
  93. session_id: session_id,
  94. user_id: user_id,
  95. timestamp: Time.current,
  96. ecommerce_data: ecommerce_data
  97. }
  98. # Track e-commerce event
  99. AnalyticsEvent.track_conversion(
  100. event_name: "ecommerce_#{event_type}",
  101. session_id: session_id,
  102. user_id: user_id,
  103. properties: ecommerce_event
  104. )
  105. end
  106. # Track content engagement with advanced metrics
  107. def track_content_engagement(content_id, content_type, engagement_data = {})
  108. return unless analytics_enabled?
  109. engagement_metrics = {
  110. content_id: content_id,
  111. content_type: content_type,
  112. timestamp: Time.current,
  113. scroll_depth: engagement_data[:scroll_depth] || 0,
  114. reading_time: engagement_data[:reading_time] || 0,
  115. interaction_events: engagement_data[:interactions] || [],
  116. exit_intent: engagement_data[:exit_intent] || false,
  117. video_engagement: engagement_data[:video_engagement] || {},
  118. form_interactions: engagement_data[:form_interactions] || []
  119. }
  120. # Store engagement data
  121. Redis.current.hset("content_engagement:#{content_id}",
  122. Time.current.to_i,
  123. engagement_metrics.to_json)
  124. # Update content analytics
  125. update_content_analytics(content_id, content_type, engagement_metrics)
  126. end
  127. # Track A/B test performance
  128. def track_ab_test(test_id, variant, session_id, user_id = nil, conversion_data = {})
  129. return unless analytics_enabled?
  130. ab_test_data = {
  131. test_id: test_id,
  132. variant: variant,
  133. session_id: session_id,
  134. user_id: user_id,
  135. timestamp: Time.current,
  136. conversion_data: conversion_data
  137. }
  138. # Store A/B test data
  139. Redis.current.sadd("ab_test:#{test_id}:variant:#{variant}", session_id)
  140. Redis.current.hset("ab_test_session:#{session_id}", test_id, variant)
  141. # Track as event
  142. AnalyticsEvent.track_conversion(
  143. event_name: "ab_test_#{test_id}",
  144. session_id: session_id,
  145. user_id: user_id,
  146. properties: ab_test_data
  147. )
  148. end
  149. # Track user lifetime value and RFM analysis
  150. def track_user_lifetime_value(user_id, transaction_data = {})
  151. return unless analytics_enabled? && user_id.present?
  152. ltv_data = {
  153. user_id: user_id,
  154. timestamp: Time.current,
  155. transaction_value: transaction_data[:value] || 0,
  156. transaction_count: transaction_data[:count] || 1,
  157. last_transaction: transaction_data[:last_transaction] || Time.current
  158. }
  159. # Update user LTV
  160. Redis.current.hincrby("user_ltv:#{user_id}", "total_value", ltv_data[:transaction_value])
  161. Redis.current.hincrby("user_ltv:#{user_id}", "transaction_count", ltv_data[:transaction_count])
  162. Redis.current.hset("user_ltv:#{user_id}", "last_transaction", ltv_data[:last_transaction].to_i)
  163. end
  164. # Track predictive analytics data
  165. def track_predictive_data(session_id, user_id = nil, predictive_features = {})
  166. return unless analytics_enabled?
  167. predictive_data = {
  168. session_id: session_id,
  169. user_id: user_id,
  170. timestamp: Time.current,
  171. features: predictive_features
  172. }
  173. # Store for ML model training
  174. Redis.current.lpush("predictive_features:#{user_id || session_id}", predictive_data.to_json)
  175. Redis.current.expire("predictive_features:#{user_id || session_id}", 90.days.to_i)
  176. end
  177. # Get comprehensive user profile
  178. def get_user_profile(user_id)
  179. return {} unless user_id.present?
  180. profile_data = {
  181. demographics: get_user_demographics(user_id),
  182. behavior: get_user_behavior(user_id),
  183. preferences: get_user_preferences(user_id),
  184. lifetime_value: get_user_ltv(user_id),
  185. cohort_data: get_user_cohorts(user_id),
  186. attribution: get_user_attribution(user_id)
  187. }
  188. profile_data
  189. end
  190. # Generate advanced reports
  191. def generate_advanced_report(report_type, params = {})
  192. case report_type.to_s
  193. when 'attribution'
  194. generate_attribution_report(params)
  195. when 'cohort'
  196. generate_cohort_report(params)
  197. when 'funnel'
  198. generate_funnel_report(params)
  199. when 'rfm'
  200. generate_rfm_report(params)
  201. when 'predictive'
  202. generate_predictive_report(params)
  203. else
  204. generate_custom_report(report_type, params)
  205. end
  206. end
  207. private
  208. def analytics_enabled?
  209. SiteSetting.get('analytics_enabled', true)
  210. end
  211. def get_attribution_data(session_id)
  212. attribution_chain = Redis.current.lrange("attribution:#{session_id}", 0, -1)
  213. attribution_chain.map { |data| JSON.parse(data) rescue nil }.compact
  214. end
  215. def update_funnel_progress(funnel_id, step, session_id)
  216. Redis.current.hset("funnel_progress:#{funnel_id}", session_id, {
  217. current_step: step,
  218. timestamp: Time.current
  219. }.to_json)
  220. end
  221. def update_content_analytics(content_id, content_type, engagement_data)
  222. # Update content analytics in background
  223. ContentAnalyticsUpdateJob.perform_later(content_id, content_type, engagement_data)
  224. end
  225. def get_user_demographics(user_id)
  226. pageviews = Pageview.where(user_id: user_id).recent(1.year.ago)
  227. {
  228. countries: pageviews.group(:country_name).count,
  229. devices: pageviews.group(:device).count,
  230. browsers: pageviews.group(:browser).count,
  231. operating_systems: pageviews.group(:operating_system).count
  232. }
  233. end
  234. def get_user_behavior(user_id)
  235. pageviews = Pageview.where(user_id: user_id).recent(1.year.ago)
  236. {
  237. avg_session_duration: pageviews.average(:time_on_page) || 0,
  238. avg_pages_per_session: pageviews.group(:session_id).count.values.mean || 0,
  239. bounce_rate: calculate_user_bounce_rate(user_id),
  240. return_visitor: pageviews.distinct.count(:session_id) > 1
  241. }
  242. end
  243. def get_user_preferences(user_id)
  244. events = AnalyticsEvent.where(user_id: user_id).recent(1.year.ago)
  245. {
  246. preferred_content_types: events.where(event_name: 'content_view').group(:properties).count,
  247. preferred_times: events.group_by_hour(:created_at).count,
  248. preferred_devices: events.joins(:pageviews).group('pageviews.device').count
  249. }
  250. end
  251. def get_user_ltv(user_id)
  252. ltv_data = Redis.current.hgetall("user_ltv:#{user_id}")
  253. {
  254. total_value: ltv_data['total_value']&.to_f || 0,
  255. transaction_count: ltv_data['transaction_count']&.to_i || 0,
  256. last_transaction: ltv_data['last_transaction']&.to_i
  257. }
  258. end
  259. def get_user_cohorts(user_id)
  260. cohort_data = Redis.current.hgetall("user_cohort:#{user_id}")
  261. cohort_data.transform_values { |data| JSON.parse(data) rescue nil }
  262. end
  263. def get_user_attribution(user_id)
  264. # Get attribution data for user's sessions
  265. user_sessions = Pageview.where(user_id: user_id).distinct.pluck(:session_id)
  266. user_sessions.map { |session_id| get_attribution_data(session_id) }.flatten
  267. end
  268. def calculate_user_bounce_rate(user_id)
  269. user_sessions = Pageview.where(user_id: user_id).group(:session_id)
  270. single_page_sessions = user_sessions.having('COUNT(*) = 1').count
  271. total_sessions = user_sessions.count
  272. return 0 if total_sessions.zero?
  273. (single_page_sessions.to_f / total_sessions * 100).round(2)
  274. end
  275. def generate_attribution_report(params)
  276. # Multi-touch attribution analysis
  277. {
  278. first_touch: get_first_touch_attribution(params),
  279. last_touch: get_last_touch_attribution(params),
  280. linear: get_linear_attribution(params),
  281. time_decay: get_time_decay_attribution(params)
  282. }
  283. end
  284. def generate_cohort_report(params)
  285. # Cohort analysis by month/week
  286. cohorts = {}
  287. (0..12).each do |i|
  288. period = i.months.ago.strftime('%Y-%m')
  289. cohorts[period] = get_cohort_data(period, params)
  290. end
  291. cohorts
  292. end
  293. def generate_funnel_report(params)
  294. # Conversion funnel analysis
  295. funnel_id = params[:funnel_id]
  296. steps = Redis.current.hgetall("funnel_progress:#{funnel_id}")
  297. {
  298. funnel_id: funnel_id,
  299. steps: steps,
  300. conversion_rates: calculate_funnel_conversion_rates(funnel_id),
  301. drop_off_points: identify_drop_off_points(funnel_id)
  302. }
  303. end
  304. def generate_rfm_report(params)
  305. # Recency, Frequency, Monetary analysis
  306. users = User.joins(:pageviews).distinct
  307. {
  308. recency: calculate_recency_segments(users),
  309. frequency: calculate_frequency_segments(users),
  310. monetary: calculate_monetary_segments(users),
  311. rfm_matrix: generate_rfm_matrix(users)
  312. }
  313. end
  314. def generate_predictive_report(params)
  315. # Predictive analytics based on ML features
  316. {
  317. churn_prediction: predict_user_churn(params),
  318. lifetime_value_prediction: predict_ltv(params),
  319. next_purchase_prediction: predict_next_purchase(params),
  320. content_recommendation: recommend_content(params)
  321. }
  322. end
  323. def generate_custom_report(report_type, params)
  324. # Custom report generation
  325. {
  326. report_type: report_type,
  327. params: params,
  328. data: generate_custom_data(report_type, params),
  329. generated_at: Time.current
  330. }
  331. end
  332. # Helper methods for report generation
  333. def get_first_touch_attribution(params)
  334. # Implementation for first-touch attribution
  335. {}
  336. end
  337. def get_last_touch_attribution(params)
  338. # Implementation for last-touch attribution
  339. {}
  340. end
  341. def get_linear_attribution(params)
  342. # Implementation for linear attribution
  343. {}
  344. end
  345. def get_time_decay_attribution(params)
  346. # Implementation for time-decay attribution
  347. {}
  348. end
  349. def get_cohort_data(period, params)
  350. # Implementation for cohort data
  351. {}
  352. end
  353. def calculate_funnel_conversion_rates(funnel_id)
  354. # Implementation for funnel conversion rates
  355. {}
  356. end
  357. def identify_drop_off_points(funnel_id)
  358. # Implementation for drop-off analysis
  359. {}
  360. end
  361. def calculate_recency_segments(users)
  362. # Implementation for recency segments
  363. {}
  364. end
  365. def calculate_frequency_segments(users)
  366. # Implementation for frequency segments
  367. {}
  368. end
  369. def calculate_monetary_segments(users)
  370. # Implementation for monetary segments
  371. {}
  372. end
  373. def generate_rfm_matrix(users)
  374. # Implementation for RFM matrix
  375. {}
  376. end
  377. def predict_user_churn(params)
  378. # Implementation for churn prediction
  379. {}
  380. end
  381. def predict_ltv(params)
  382. # Implementation for LTV prediction
  383. {}
  384. end
  385. def predict_next_purchase(params)
  386. # Implementation for next purchase prediction
  387. {}
  388. end
  389. def recommend_content(params)
  390. # Implementation for content recommendation
  391. {}
  392. end
  393. def generate_custom_data(report_type, params)
  394. # Implementation for custom data generation
  395. {}
  396. end
  397. end
  398. end

app/services/ai_helper.rb

0.0% lines covered

100.0% branches covered

62 relevant lines. 0 lines covered and 62 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiHelper
  2. class << self
  3. # Execute an AI agent by type
  4. def execute_agent(agent_type, user_input = "", context = {})
  5. agent = AiAgent.active.find_by(agent_type: agent_type)
  6. return { success: false, error: "No active agent found for type: #{agent_type}" } unless agent
  7. begin
  8. result = agent.execute(user_input, context)
  9. { success: true, result: result, agent: agent }
  10. rescue => e
  11. { success: false, error: e.message }
  12. end
  13. end
  14. # Generate content using the Post Writer agent
  15. def generate_post_content(topic, tone = "professional", additional_context = {})
  16. context = {
  17. tone: tone,
  18. target_audience: additional_context[:target_audience] || "general audience",
  19. word_count: additional_context[:word_count] || "800-1200",
  20. keywords: additional_context[:keywords] || ""
  21. }.merge(additional_context)
  22. execute_agent('post_writer', topic, context)
  23. end
  24. # Summarize content using the Content Summarizer agent
  25. def summarize_content(content, summary_length = "medium")
  26. context = {
  27. content: content,
  28. length: summary_length
  29. }
  30. execute_agent('content_summarizer', content, context)
  31. end
  32. # Analyze comments using the Comments Analyzer agent
  33. def analyze_comments(comments)
  34. context = {
  35. comments: comments
  36. }
  37. execute_agent('comments_analyzer', comments, context)
  38. end
  39. # Analyze SEO using the SEO Analyzer agent
  40. def analyze_seo(content, target_keywords = [])
  41. context = {
  42. content: content,
  43. target_keywords: target_keywords.join(', '),
  44. url: content[:url] if content.is_a?(Hash),
  45. title: content[:title] if content.is_a?(Hash)
  46. }
  47. execute_agent('seo_analyzer', content.is_a?(Hash) ? content[:content] || content[:text] : content, context)
  48. end
  49. # Get available agent types
  50. def available_agents
  51. AiAgent.active.pluck(:agent_type).uniq
  52. end
  53. # Check if an agent type is available
  54. def agent_available?(agent_type)
  55. AiAgent.active.exists?(agent_type: agent_type)
  56. end
  57. # Get agent info
  58. def agent_info(agent_type)
  59. agent = AiAgent.active.find_by(agent_type: agent_type)
  60. return nil unless agent
  61. {
  62. id: agent.id,
  63. name: agent.name,
  64. description: agent.description,
  65. provider: agent.ai_provider.name,
  66. model: agent.ai_provider.model_identifier
  67. }
  68. end
  69. end
  70. end

app/services/ai_service.rb

0.0% lines covered

100.0% branches covered

128 relevant lines. 0 lines covered and 128 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiService
  2. def initialize(provider)
  3. @provider = provider
  4. end
  5. def generate(prompt)
  6. case @provider.provider_type
  7. when 'openai'
  8. call_openai(prompt)
  9. when 'cohere'
  10. call_cohere(prompt)
  11. when 'anthropic'
  12. call_anthropic(prompt)
  13. when 'google'
  14. call_google(prompt)
  15. else
  16. raise "Unsupported provider type: #{@provider.provider_type}"
  17. end
  18. end
  19. private
  20. def call_openai(prompt)
  21. require 'net/http'
  22. require 'json'
  23. uri = URI('https://api.openai.com/v1/chat/completions')
  24. http = Net::HTTP.new(uri.host, uri.port)
  25. http.use_ssl = true
  26. request = Net::HTTP::Post.new(uri)
  27. request['Authorization'] = "Bearer #{@provider.api_key}"
  28. request['Content-Type'] = 'application/json'
  29. body = {
  30. model: @provider.model_identifier,
  31. messages: [{ role: "user", content: prompt }],
  32. max_tokens: @provider.max_tokens,
  33. temperature: @provider.temperature
  34. }
  35. request.body = body.to_json
  36. response = http.request(request)
  37. if response.code == '200'
  38. parsed_response = JSON.parse(response.body)
  39. content = parsed_response.dig('choices', 0, 'message', 'content')
  40. raise "Invalid response format: missing content" if content.nil?
  41. content
  42. else
  43. raise "OpenAI API error: #{response.body}"
  44. end
  45. rescue => e
  46. raise e
  47. end
  48. def call_cohere(prompt)
  49. require 'net/http'
  50. require 'json'
  51. uri = URI('https://api.cohere.ai/v1/chat')
  52. http = Net::HTTP.new(uri.host, uri.port)
  53. http.use_ssl = true
  54. request = Net::HTTP::Post.new(uri)
  55. request['Authorization'] = "Bearer #{@provider.api_key}"
  56. request['Content-Type'] = 'application/json'
  57. body = {
  58. model: @provider.model_identifier,
  59. message: prompt,
  60. max_tokens: @provider.max_tokens.to_i,
  61. temperature: @provider.temperature.to_f,
  62. stream: false
  63. }
  64. request.body = body.to_json
  65. response = http.request(request)
  66. if response.code == '200'
  67. JSON.parse(response.body)['text']
  68. else
  69. raise "Cohere API error: #{response.body}"
  70. end
  71. rescue => e
  72. raise e
  73. end
  74. def call_anthropic(prompt)
  75. require 'net/http'
  76. require 'json'
  77. uri = URI('https://api.anthropic.com/v1/messages')
  78. http = Net::HTTP.new(uri.host, uri.port)
  79. http.use_ssl = true
  80. request = Net::HTTP::Post.new(uri)
  81. request['x-api-key'] = @provider.api_key
  82. request['Content-Type'] = 'application/json'
  83. request['anthropic-version'] = '2023-06-01'
  84. body = {
  85. model: @provider.model_identifier,
  86. max_tokens: @provider.max_tokens,
  87. temperature: @provider.temperature,
  88. messages: [{ role: "user", content: prompt }]
  89. }
  90. request.body = body.to_json
  91. response = http.request(request)
  92. if response.code == '200'
  93. JSON.parse(response.body)['content'][0]['text']
  94. else
  95. raise "Anthropic API error: #{response.body}"
  96. end
  97. rescue => e
  98. raise e
  99. end
  100. def call_google(prompt)
  101. require 'net/http'
  102. require 'json'
  103. uri = URI("https://generativelanguage.googleapis.com/v1beta/models/#{@provider.model_identifier}:generateContent")
  104. uri.query = URI.encode_www_form(key: @provider.api_key)
  105. http = Net::HTTP.new(uri.host, uri.port)
  106. http.use_ssl = true
  107. request = Net::HTTP::Post.new(uri)
  108. request['Content-Type'] = 'application/json'
  109. body = {
  110. contents: [{
  111. parts: [{ text: prompt }]
  112. }],
  113. generationConfig: {
  114. maxOutputTokens: @provider.max_tokens,
  115. temperature: @provider.temperature
  116. }
  117. }
  118. request.body = body.to_json
  119. response = http.request(request)
  120. if response.code == '200'
  121. JSON.parse(response.body)['candidates'][0]['content']['parts'][0]['text']
  122. else
  123. raise "Google API error: #{response.body}"
  124. end
  125. rescue => e
  126. raise e
  127. end
  128. end

app/services/akismet_service.rb

0.0% lines covered

100.0% branches covered

76 relevant lines. 0 lines covered and 76 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AkismetService
  2. AKISMET_URL = 'https://rest.akismet.com/1.1'
  3. def initialize(api_key, site_url)
  4. @api_key = api_key
  5. @site_url = site_url
  6. @blog = site_url
  7. end
  8. # Check if a comment is spam
  9. def spam?(comment_data)
  10. return false unless @api_key.present?
  11. begin
  12. response = make_request('comment-check', comment_data)
  13. Rails.logger.info "Akismet response: #{response}"
  14. response.strip == 'true'
  15. rescue => e
  16. Rails.logger.error "Akismet error: #{e.message}"
  17. false # Don't block comments if Akismet fails
  18. end
  19. end
  20. # Verify the API key is valid
  21. def verify_key
  22. return false unless @api_key.present?
  23. begin
  24. response = make_request('verify-key', {
  25. key: @api_key,
  26. blog: @blog
  27. })
  28. Rails.logger.info "Akismet key verification: #{response}"
  29. response.strip == 'valid'
  30. rescue => e
  31. Rails.logger.error "Akismet key verification error: #{e.message}"
  32. false
  33. end
  34. end
  35. # Submit a false positive (ham)
  36. def submit_ham(comment_data)
  37. return false unless @api_key.present?
  38. begin
  39. response = make_request('submit-ham', comment_data)
  40. Rails.logger.info "Akismet submit ham: #{response}"
  41. response.strip == 'Thanks for making the web a better place.'
  42. rescue => e
  43. Rails.logger.error "Akismet submit ham error: #{e.message}"
  44. false
  45. end
  46. end
  47. # Submit a false negative (spam)
  48. def submit_spam(comment_data)
  49. return false unless @api_key.present?
  50. begin
  51. response = make_request('submit-spam', comment_data)
  52. Rails.logger.info "Akismet submit spam: #{response}"
  53. response.strip == 'Thanks for making the web a better place.'
  54. rescue => e
  55. Rails.logger.error "Akismet submit spam error: #{e.message}"
  56. false
  57. end
  58. end
  59. private
  60. def make_request(action, data)
  61. uri = URI("#{AKISMET_URL}/#{action}")
  62. # Add API key to the data
  63. request_data = {
  64. blog: @blog,
  65. key: @api_key
  66. }.merge(data)
  67. http = Net::HTTP.new(uri.host, uri.port)
  68. http.use_ssl = true
  69. http.read_timeout = 10
  70. http.open_timeout = 10
  71. request = Net::HTTP::Post.new(uri)
  72. request.set_form_data(request_data)
  73. request['User-Agent'] = "RailsPress/1.0 | Akismet/1.0"
  74. response = http.request(request)
  75. if response.code == '200'
  76. response.body
  77. else
  78. raise "Akismet API error: #{response.code} #{response.message}"
  79. end
  80. end
  81. end

app/services/analytics_archive_service.rb

0.0% lines covered

100.0% branches covered

235 relevant lines. 0 lines covered and 235 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsArchiveService
  2. include Singleton
  3. def initialize
  4. @default_retention_days = SiteSetting.get('analytics_data_retention_days', 365).to_i
  5. @archive_enabled = SiteSetting.get('analytics_archive_enabled', true)
  6. @export_format = SiteSetting.get('analytics_export_format', 'json') # json, csv, parquet
  7. end
  8. # Archive old analytics data
  9. def archive_old_data
  10. return unless @archive_enabled
  11. cutoff_date = @default_retention_days.days.ago
  12. Rails.logger.info "Starting analytics data archival for data older than #{cutoff_date}"
  13. archived_count = 0
  14. # Archive old pageviews
  15. archived_count += archive_pageviews(cutoff_date)
  16. # Archive old analytics events
  17. archived_count += archive_analytics_events(cutoff_date)
  18. Rails.logger.info "Archived #{archived_count} analytics records"
  19. # Clean up archived data if configured
  20. if SiteSetting.get('analytics_auto_delete_archived', false)
  21. cleanup_archived_data
  22. end
  23. archived_count
  24. end
  25. # Export analytics data for specific date range
  26. def export_data(start_date, end_date, format: @export_format, include_events: true)
  27. Rails.logger.info "Exporting analytics data from #{start_date} to #{end_date} in #{format} format"
  28. export_data = {
  29. metadata: {
  30. export_date: Time.current,
  31. date_range: { start: start_date, end: end_date },
  32. format: format,
  33. version: '1.0'
  34. },
  35. pageviews: export_pageviews(start_date, end_date),
  36. events: include_events ? export_analytics_events(start_date, end_date) : []
  37. }
  38. case format
  39. when 'json'
  40. export_data.to_json
  41. when 'csv'
  42. convert_to_csv(export_data)
  43. when 'parquet'
  44. convert_to_parquet(export_data)
  45. else
  46. export_data.to_json
  47. end
  48. end
  49. # Get archive statistics
  50. def archive_stats
  51. {
  52. total_pageviews: Pageview.count,
  53. total_events: AnalyticsEvent.count,
  54. archived_pageviews: ArchivedPageview.count,
  55. archived_events: ArchivedAnalyticsEvent.count,
  56. oldest_data: oldest_data_date,
  57. retention_policy: {
  58. days: @default_retention_days,
  59. enabled: @archive_enabled,
  60. auto_delete: SiteSetting.get('analytics_auto_delete_archived', false)
  61. }
  62. }
  63. end
  64. # Schedule automatic archiving
  65. def schedule_auto_archive
  66. return unless @archive_enabled
  67. frequency = SiteSetting.get('analytics_archive_frequency', 'daily') # daily, weekly, monthly
  68. case frequency
  69. when 'daily'
  70. AnalyticsArchiveJob.perform_in(1.day)
  71. when 'weekly'
  72. AnalyticsArchiveJob.perform_in(1.week)
  73. when 'monthly'
  74. AnalyticsArchiveJob.perform_in(1.month)
  75. end
  76. end
  77. private
  78. def archive_pageviews(cutoff_date)
  79. old_pageviews = Pageview.where('visited_at < ?', cutoff_date)
  80. count = old_pageviews.count
  81. return 0 if count == 0
  82. # Archive in batches to avoid memory issues
  83. old_pageviews.find_in_batches(batch_size: 1000) do |batch|
  84. archived_data = batch.map do |pv|
  85. {
  86. id: pv.id,
  87. path: pv.path,
  88. title: pv.title,
  89. referrer: pv.referrer,
  90. user_agent: pv.user_agent,
  91. browser: pv.browser,
  92. device: pv.device,
  93. os: pv.os,
  94. ip_hash: pv.ip_hash,
  95. session_id: pv.session_id,
  96. user_id: pv.user_id,
  97. post_id: pv.post_id,
  98. page_id: pv.page_id,
  99. unique_visitor: pv.unique_visitor,
  100. returning_visitor: pv.returning_visitor,
  101. bot: pv.bot,
  102. consented: pv.consented,
  103. visited_at: pv.visited_at,
  104. metadata: pv.metadata,
  105. tenant_id: pv.tenant_id,
  106. reading_time: pv.reading_time,
  107. scroll_depth: pv.scroll_depth,
  108. completion_rate: pv.completion_rate,
  109. time_on_page: pv.time_on_page,
  110. exit_intent: pv.exit_intent,
  111. country_code: pv.country_code,
  112. country_name: pv.country_name,
  113. city: pv.city,
  114. region: pv.region,
  115. latitude: pv.latitude,
  116. longitude: pv.longitude,
  117. timezone: pv.timezone,
  118. archived_at: Time.current
  119. }
  120. end
  121. ArchivedPageview.insert_all(archived_data)
  122. end
  123. # Delete original records
  124. old_pageviews.delete_all
  125. count
  126. end
  127. def archive_analytics_events(cutoff_date)
  128. old_events = AnalyticsEvent.where('created_at < ?', cutoff_date)
  129. count = old_events.count
  130. return 0 if count == 0
  131. # Archive in batches
  132. old_events.find_in_batches(batch_size: 1000) do |batch|
  133. archived_data = batch.map do |event|
  134. {
  135. id: event.id,
  136. event_name: event.event_name,
  137. properties: event.properties,
  138. session_id: event.session_id,
  139. user_id: event.user_id,
  140. tenant_id: event.tenant_id,
  141. created_at: event.created_at,
  142. archived_at: Time.current
  143. }
  144. end
  145. ArchivedAnalyticsEvent.insert_all(archived_data)
  146. end
  147. # Delete original records
  148. old_events.delete_all
  149. count
  150. end
  151. def export_pageviews(start_date, end_date)
  152. Pageview.where(visited_at: start_date..end_date)
  153. .includes(:tenant)
  154. .map do |pv|
  155. {
  156. id: pv.id,
  157. path: pv.path,
  158. title: pv.title,
  159. referrer: pv.referrer,
  160. browser: pv.browser,
  161. device: pv.device,
  162. os: pv.os,
  163. unique_visitor: pv.unique_visitor,
  164. returning_visitor: pv.returning_visitor,
  165. bot: pv.bot,
  166. consented: pv.consented,
  167. visited_at: pv.visited_at,
  168. reading_time: pv.reading_time,
  169. scroll_depth: pv.scroll_depth,
  170. completion_rate: pv.completion_rate,
  171. time_on_page: pv.time_on_page,
  172. country_code: pv.country_code,
  173. country_name: pv.country_name,
  174. city: pv.city,
  175. region: pv.region,
  176. tenant_name: pv.tenant&.name
  177. }
  178. end
  179. end
  180. def export_analytics_events(start_date, end_date)
  181. AnalyticsEvent.where(created_at: start_date..end_date)
  182. .includes(:tenant)
  183. .map do |event|
  184. {
  185. id: event.id,
  186. event_name: event.event_name,
  187. properties: event.properties,
  188. session_id: event.session_id,
  189. user_id: event.user_id,
  190. created_at: event.created_at,
  191. tenant_name: event.tenant&.name
  192. }
  193. end
  194. end
  195. def convert_to_csv(data)
  196. require 'csv'
  197. csv_string = CSV.generate do |csv|
  198. # Header
  199. csv << ['Type', 'ID', 'Date', 'Path', 'Event', 'Properties', 'Country', 'Device', 'Browser', 'Tenant']
  200. # Pageviews
  201. data[:pageviews].each do |pv|
  202. csv << [
  203. 'pageview',
  204. pv[:id],
  205. pv[:visited_at],
  206. pv[:path],
  207. nil,
  208. nil,
  209. pv[:country_name],
  210. pv[:device],
  211. pv[:browser],
  212. pv[:tenant_name]
  213. ]
  214. end
  215. # Events
  216. data[:events].each do |event|
  217. csv << [
  218. 'event',
  219. event[:id],
  220. event[:created_at],
  221. nil,
  222. event[:event_name],
  223. event[:properties].to_json,
  224. nil,
  225. nil,
  226. nil,
  227. event[:tenant_name]
  228. ]
  229. end
  230. end
  231. csv_string
  232. end
  233. def convert_to_parquet(data)
  234. # For now, return JSON - could implement Parquet export with ruby-parquet gem
  235. data.to_json
  236. end
  237. def cleanup_archived_data_details
  238. cutoff_date = (@default_retention_days * 2).days.ago # Keep archived data for 2x retention period
  239. ArchivedPageview.where('archived_at < ?', cutoff_date).delete_all
  240. ArchivedAnalyticsEvent.where('archived_at < ?', cutoff_date).delete_all
  241. end
  242. def oldest_data_date
  243. [
  244. Pageview.minimum(:visited_at),
  245. AnalyticsEvent.minimum(:created_at),
  246. ArchivedPageview.minimum(:visited_at),
  247. ArchivedAnalyticsEvent.minimum(:created_at)
  248. ].compact.min
  249. end
  250. end

app/services/analytics_retention_service.rb

0.0% lines covered

100.0% branches covered

63 relevant lines. 0 lines covered and 63 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AnalyticsRetentionService
  2. # Clean up old analytics data to prevent database bloat
  3. def self.cleanup_old_data
  4. retention_days = SiteSetting.get('analytics_data_retention_days', 365)
  5. cutoff_date = retention_days.days.ago
  6. # Archive old pageviews before deletion
  7. archive_old_pageviews(cutoff_date)
  8. # Delete old analytics events
  9. old_events_count = AnalyticsEvent.where('created_at < ?', cutoff_date).count
  10. AnalyticsEvent.where('created_at < ?', cutoff_date).delete_all
  11. # Delete old pageviews (keep only essential data)
  12. old_pageviews_count = Pageview.where('visited_at < ?', cutoff_date).count
  13. Pageview.where('visited_at < ?', cutoff_date).delete_all
  14. Rails.logger.info "Analytics cleanup completed: #{old_pageviews_count} pageviews and #{old_events_count} events removed"
  15. {
  16. pageviews_deleted: old_pageviews_count,
  17. events_deleted: old_events_count,
  18. cutoff_date: cutoff_date
  19. }
  20. end
  21. # Archive old pageviews to compressed files
  22. def self.archive_old_pageviews(cutoff_date)
  23. return unless SiteSetting.get('analytics_archive_enabled', true)
  24. # Create archive directory
  25. archive_dir = Rails.root.join('storage', 'analytics_archive')
  26. FileUtils.mkdir_p(archive_dir)
  27. # Get old pageviews in batches
  28. batch_size = 10000
  29. total_archived = 0
  30. Pageview.where('visited_at < ?', cutoff_date).find_in_batches(batch_size: batch_size) do |batch|
  31. archive_data = batch.map do |pv|
  32. {
  33. path: pv.path,
  34. title: pv.title,
  35. visited_at: pv.visited_at,
  36. session_id: pv.session_id,
  37. is_reader: pv.is_reader,
  38. engagement_score: pv.engagement_score,
  39. reading_time: pv.reading_time,
  40. country_code: pv.country_code
  41. }
  42. end
  43. # Write to compressed archive file
  44. archive_filename = "pageviews_#{cutoff_date.strftime('%Y%m')}.json.gz"
  45. archive_path = archive_dir.join(archive_filename)
  46. File.open(archive_path, 'a') do |file|
  47. file.write(Zlib::Deflate.deflate(JSON.dump(archive_data)))
  48. end
  49. total_archived += batch.size
  50. end
  51. Rails.logger.info "Archived #{total_archived} pageviews to #{archive_dir}"
  52. total_archived
  53. end
  54. # Get analytics summary for archived data
  55. def self.archived_summary(year, month)
  56. archive_dir = Rails.root.join('storage', 'analytics_archive')
  57. archive_filename = "pageviews_#{year}#{month.to_s.rjust(2, '0')}.json.gz"
  58. archive_path = archive_dir.join(archive_filename)
  59. return {} unless File.exist?(archive_path)
  60. archived_data = JSON.parse(Zlib::Inflate.inflate(File.read(archive_path)))
  61. {
  62. total_pageviews: archived_data.size,
  63. unique_readers: archived_data.count { |pv| pv['is_reader'] },
  64. avg_engagement: archived_data.sum { |pv| pv['engagement_score'] || 0 } / archived_data.size.to_f,
  65. top_pages: archived_data.group_by { |pv| pv['path'] }
  66. .transform_values(&:size)
  67. .sort_by { |_, count| -count }
  68. .first(10)
  69. .to_h
  70. }
  71. end
  72. end

app/services/analytics_security_service.rb

0.0% lines covered

100.0% branches covered

374 relevant lines. 0 lines covered and 374 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AnalyticsSecurityService
  3. # Advanced security measures for analytics data
  4. class << self
  5. # Encrypt sensitive analytics data
  6. def encrypt_sensitive_data(data, user_id = nil)
  7. return data unless data.is_a?(Hash)
  8. encrypted_data = data.dup
  9. # Encrypt PII fields
  10. sensitive_fields = %w[email phone name address ip_address user_agent]
  11. sensitive_fields.each do |field|
  12. if encrypted_data[field].present?
  13. encrypted_data[field] = encrypt_field(encrypted_data[field], user_id)
  14. end
  15. end
  16. # Encrypt nested PII
  17. if encrypted_data[:properties].is_a?(Hash)
  18. encrypted_data[:properties] = encrypt_sensitive_data(encrypted_data[:properties], user_id)
  19. end
  20. encrypted_data
  21. end
  22. # Decrypt sensitive analytics data (admin only)
  23. def decrypt_sensitive_data(encrypted_data, user_id = nil, admin_user = nil)
  24. return encrypted_data unless admin_user&.administrator?
  25. decrypted_data = encrypted_data.dup
  26. # Decrypt PII fields
  27. sensitive_fields = %w[email phone name address ip_address user_agent]
  28. sensitive_fields.each do |field|
  29. if decrypted_data[field].present? && is_encrypted?(decrypted_data[field])
  30. decrypted_data[field] = decrypt_field(decrypted_data[field], user_id)
  31. end
  32. end
  33. # Decrypt nested PII
  34. if decrypted_data[:properties].is_a?(Hash)
  35. decrypted_data[:properties] = decrypt_sensitive_data(decrypted_data[:properties], user_id, admin_user)
  36. end
  37. decrypted_data
  38. end
  39. # Anonymize IP addresses based on GDPR settings
  40. def anonymize_ip(ip_address, anonymization_level = :full)
  41. return nil if ip_address.blank?
  42. case anonymization_level
  43. when :full
  44. # Full anonymization - remove last octet
  45. parts = ip_address.split('.')
  46. parts[3] = '0' if parts.length == 4
  47. parts.join('.')
  48. when :partial
  49. # Partial anonymization - remove last two octets
  50. parts = ip_address.split('.')
  51. parts[2] = '0'
  52. parts[3] = '0' if parts.length == 4
  53. parts.join('.')
  54. when :none
  55. # No anonymization (only if GDPR consent given)
  56. ip_address
  57. else
  58. # Default to full anonymization
  59. anonymize_ip(ip_address, :full)
  60. end
  61. end
  62. # Hash user identifiers for privacy
  63. def hash_user_identifier(identifier, salt = nil)
  64. return nil if identifier.blank?
  65. salt ||= Rails.application.secrets.secret_key_base
  66. Digest::SHA256.hexdigest("#{identifier}#{salt}")
  67. end
  68. # Generate secure session IDs
  69. def generate_secure_session_id
  70. SecureRandom.hex(32)
  71. end
  72. # Validate analytics request authenticity
  73. def validate_request_authenticity(request)
  74. # Check for valid CSRF token
  75. return false unless valid_csrf_token?(request)
  76. # Check for suspicious patterns
  77. return false if suspicious_request?(request)
  78. # Check rate limiting
  79. return false if rate_limited?(request)
  80. true
  81. end
  82. # Implement data retention policies
  83. def apply_data_retention_policy(data_type, record_age)
  84. retention_days = get_retention_period(data_type)
  85. return false if retention_days.nil?
  86. record_age > retention_days.days
  87. end
  88. # Audit analytics data access
  89. def audit_data_access(user_id, data_type, action, admin_user = nil)
  90. audit_data = {
  91. user_id: user_id,
  92. data_type: data_type,
  93. action: action,
  94. admin_user_id: admin_user&.id,
  95. timestamp: Time.current,
  96. ip_address: anonymize_ip(get_current_ip),
  97. user_agent: get_current_user_agent
  98. }
  99. # Store audit log
  100. AnalyticsAuditLog.create!(audit_data)
  101. # Check for suspicious access patterns
  102. check_suspicious_access_patterns(user_id, data_type, action)
  103. end
  104. # Implement data masking for non-admin users
  105. def mask_sensitive_data(data, user_role = :user)
  106. return data if user_role == :admin
  107. masked_data = data.dup
  108. # Mask PII fields
  109. pii_fields = %w[email phone name address ip_address]
  110. pii_fields.each do |field|
  111. if masked_data[field].present?
  112. masked_data[field] = mask_field(masked_data[field])
  113. end
  114. end
  115. masked_data
  116. end
  117. # Implement data pseudonymization
  118. def pseudonymize_data(data, pseudonymization_key)
  119. return data unless data.is_a?(Hash)
  120. pseudonymized_data = data.dup
  121. # Pseudonymize identifiers
  122. identifier_fields = %w[user_id session_id device_id]
  123. identifier_fields.each do |field|
  124. if pseudonymized_data[field].present?
  125. pseudonymized_data[field] = hash_user_identifier(
  126. pseudonymized_data[field],
  127. pseudonymization_key
  128. )
  129. end
  130. end
  131. pseudonymized_data
  132. end
  133. # Implement data minimization
  134. def minimize_data_collection(data, purpose)
  135. return data unless data.is_a?(Hash)
  136. # Define minimal data sets for different purposes
  137. minimal_sets = {
  138. analytics: %w[page_path timestamp device browser],
  139. marketing: %w[user_id preferences interests],
  140. security: %w[ip_address user_agent timestamp],
  141. performance: %w[page_load_time resource_metrics]
  142. }
  143. allowed_fields = minimal_sets[purpose.to_sym] || minimal_sets[:analytics]
  144. data.select { |key, _| allowed_fields.include?(key.to_s) }
  145. end
  146. # Implement consent management
  147. def manage_consent(user_id, consent_type, granted, purpose = nil)
  148. consent_data = {
  149. user_id: user_id,
  150. consent_type: consent_type,
  151. granted: granted,
  152. purpose: purpose,
  153. timestamp: Time.current,
  154. ip_address: anonymize_ip(get_current_ip),
  155. user_agent: get_current_user_agent
  156. }
  157. # Store consent record
  158. AnalyticsConsent.create!(consent_data)
  159. # Update user consent status
  160. update_user_consent_status(user_id, consent_type, granted)
  161. # Apply consent-based data processing
  162. apply_consent_based_processing(user_id, consent_type, granted)
  163. end
  164. # Implement data portability
  165. def export_user_data(user_id, format = :json)
  166. user_data = collect_user_data(user_id)
  167. case format
  168. when :json
  169. export_json_data(user_data)
  170. when :csv
  171. export_csv_data(user_data)
  172. when :xml
  173. export_xml_data(user_data)
  174. else
  175. export_json_data(user_data)
  176. end
  177. end
  178. # Implement right to be forgotten
  179. def delete_user_data(user_id, data_types = :all)
  180. deletion_log = {
  181. user_id: user_id,
  182. data_types: data_types,
  183. timestamp: Time.current,
  184. admin_user_id: get_current_admin_user&.id
  185. }
  186. case data_types
  187. when :all
  188. delete_all_user_data(user_id)
  189. when :analytics
  190. delete_analytics_data(user_id)
  191. when :personal
  192. delete_personal_data(user_id)
  193. else
  194. delete_specific_data_types(user_id, data_types)
  195. end
  196. # Log deletion
  197. AnalyticsDataDeletion.create!(deletion_log)
  198. end
  199. # Implement data breach detection
  200. def detect_data_breach(user_id = nil)
  201. breach_indicators = {
  202. unusual_access_patterns: detect_unusual_access_patterns(user_id),
  203. suspicious_requests: detect_suspicious_requests(user_id),
  204. data_exfiltration: detect_data_exfiltration(user_id),
  205. unauthorized_access: detect_unauthorized_access(user_id)
  206. }
  207. if breach_indicators.values.any?
  208. handle_potential_breach(user_id, breach_indicators)
  209. end
  210. breach_indicators
  211. end
  212. private
  213. def encrypt_field(value, user_id)
  214. return value if value.blank?
  215. key = generate_encryption_key(user_id)
  216. cipher = OpenSSL::Cipher.new('AES-256-GCM')
  217. cipher.encrypt
  218. cipher.key = key
  219. encrypted = cipher.update(value) + cipher.final
  220. "#{cipher.iv.unpack1('H*')}:#{encrypted.unpack1('H*')}"
  221. end
  222. def decrypt_field(encrypted_value, user_id)
  223. return encrypted_value unless is_encrypted?(encrypted_value)
  224. iv_hex, encrypted_hex = encrypted_value.split(':')
  225. return encrypted_value unless iv_hex && encrypted_hex
  226. key = generate_encryption_key(user_id)
  227. cipher = OpenSSL::Cipher.new('AES-256-GCM')
  228. cipher.decrypt
  229. cipher.key = key
  230. cipher.iv = [iv_hex].pack('H*')
  231. encrypted_data = [encrypted_hex].pack('H*')
  232. cipher.update(encrypted_data) + cipher.final
  233. rescue => e
  234. Rails.logger.error "Decryption failed: #{e.message}"
  235. encrypted_value
  236. end
  237. def is_encrypted?(value)
  238. value.is_a?(String) && value.include?(':') && value.length > 64
  239. end
  240. def generate_encryption_key(user_id)
  241. salt = Rails.application.secrets.secret_key_base
  242. Digest::SHA256.digest("#{user_id}#{salt}")
  243. end
  244. def valid_csrf_token?(request)
  245. # Implement CSRF validation
  246. true # Simplified for now
  247. end
  248. def suspicious_request?(request)
  249. # Check for suspicious patterns
  250. user_agent = request.user_agent.to_s.downcase
  251. # Block known bot patterns
  252. bot_patterns = %w[bot crawler spider scraper]
  253. return true if bot_patterns.any? { |pattern| user_agent.include?(pattern) }
  254. # Check for unusual request patterns
  255. ip_address = request.remote_ip
  256. request_count = Redis.current.get("request_count:#{ip_address}").to_i
  257. return true if request_count > 100 # Rate limit exceeded
  258. # Update request count
  259. Redis.current.incr("request_count:#{ip_address}")
  260. Redis.current.expire("request_count:#{ip_address}", 1.hour.to_i)
  261. false
  262. end
  263. def rate_limited?(request)
  264. ip_address = request.remote_ip
  265. key = "rate_limit:#{ip_address}:#{Time.current.to_i / 60}"
  266. current_count = Redis.current.get(key).to_i
  267. return true if current_count >= 60 # 60 requests per minute
  268. Redis.current.incr(key)
  269. Redis.current.expire(key, 1.minute.to_i)
  270. false
  271. end
  272. def get_retention_period(data_type)
  273. case data_type
  274. when :analytics
  275. SiteSetting.get('analytics_data_retention_days', 365).to_i
  276. when :personal
  277. SiteSetting.get('personal_data_retention_days', 30).to_i
  278. when :marketing
  279. SiteSetting.get('marketing_data_retention_days', 90).to_i
  280. else
  281. SiteSetting.get('default_data_retention_days', 365).to_i
  282. end
  283. end
  284. def get_current_ip
  285. # Get current request IP
  286. Thread.current[:current_request]&.remote_ip || '127.0.0.1'
  287. end
  288. def get_current_user_agent
  289. # Get current request user agent
  290. Thread.current[:current_request]&.user_agent || 'Unknown'
  291. end
  292. def get_current_admin_user
  293. # Get current admin user from thread
  294. Thread.current[:current_admin_user]
  295. end
  296. def mask_field(value)
  297. return value if value.blank?
  298. if value.include?('@')
  299. # Email masking
  300. parts = value.split('@')
  301. parts[0] = "#{parts[0][0]}***"
  302. parts.join('@')
  303. elsif value.match?(/^\d+$/)
  304. # Phone number masking
  305. "#{value[0..2]}***#{value[-2..-1]}"
  306. else
  307. # General masking
  308. "#{value[0..2]}***"
  309. end
  310. end
  311. def update_user_consent_status(user_id, consent_type, granted)
  312. # Update user consent status in Redis/database
  313. Redis.current.hset("user_consent:#{user_id}", consent_type, granted)
  314. end
  315. def apply_consent_based_processing(user_id, consent_type, granted)
  316. # Apply consent-based data processing rules
  317. case consent_type
  318. when :analytics
  319. if granted
  320. enable_analytics_tracking(user_id)
  321. else
  322. disable_analytics_tracking(user_id)
  323. end
  324. when :marketing
  325. if granted
  326. enable_marketing_tracking(user_id)
  327. else
  328. disable_marketing_tracking(user_id)
  329. end
  330. end
  331. end
  332. def enable_analytics_tracking(user_id)
  333. Redis.current.hset("user_tracking:#{user_id}", "analytics_enabled", true)
  334. end
  335. def disable_analytics_tracking(user_id)
  336. Redis.current.hset("user_tracking:#{user_id}", "analytics_enabled", false)
  337. end
  338. def enable_marketing_tracking(user_id)
  339. Redis.current.hset("user_tracking:#{user_id}", "marketing_enabled", true)
  340. end
  341. def disable_marketing_tracking(user_id)
  342. Redis.current.hset("user_tracking:#{user_id}", "marketing_enabled", false)
  343. end
  344. def collect_user_data(user_id)
  345. {
  346. pageviews: Pageview.where(user_id: user_id).limit(1000),
  347. events: AnalyticsEvent.where(user_id: user_id).limit(1000),
  348. consent_records: AnalyticsConsent.where(user_id: user_id),
  349. profile_data: get_user_profile_data(user_id)
  350. }
  351. end
  352. def export_json_data(data)
  353. data.to_json
  354. end
  355. def export_csv_data(data)
  356. # Convert to CSV format
  357. CSV.generate do |csv|
  358. data.each do |key, records|
  359. if records.is_a?(ActiveRecord::Relation)
  360. csv << [key.to_s]
  361. records.each { |record| csv << record.attributes.values }
  362. end
  363. end
  364. end
  365. end
  366. def export_xml_data(data)
  367. data.to_xml
  368. end
  369. def delete_all_user_data(user_id)
  370. Pageview.where(user_id: user_id).delete_all
  371. AnalyticsEvent.where(user_id: user_id).delete_all
  372. AnalyticsConsent.where(user_id: user_id).delete_all
  373. AnalyticsAuditLog.where(user_id: user_id).delete_all
  374. end
  375. def delete_analytics_data(user_id)
  376. Pageview.where(user_id: user_id).delete_all
  377. AnalyticsEvent.where(user_id: user_id).delete_all
  378. end
  379. def delete_personal_data(user_id)
  380. # Delete personal data while keeping analytics data anonymized
  381. AnalyticsEvent.where(user_id: user_id).update_all(user_id: nil)
  382. Pageview.where(user_id: user_id).update_all(user_id: nil)
  383. end
  384. def delete_specific_data_types(user_id, data_types)
  385. data_types.each do |data_type|
  386. case data_type
  387. when :pageviews
  388. Pageview.where(user_id: user_id).delete_all
  389. when :events
  390. AnalyticsEvent.where(user_id: user_id).delete_all
  391. when :consent
  392. AnalyticsConsent.where(user_id: user_id).delete_all
  393. end
  394. end
  395. end
  396. def detect_unusual_access_patterns(user_id)
  397. # Detect unusual access patterns
  398. false # Simplified for now
  399. end
  400. def detect_suspicious_requests(user_id)
  401. # Detect suspicious requests
  402. false # Simplified for now
  403. end
  404. def detect_data_exfiltration(user_id)
  405. # Detect data exfiltration attempts
  406. false # Simplified for now
  407. end
  408. def detect_unauthorized_access(user_id)
  409. # Detect unauthorized access
  410. false # Simplified for now
  411. end
  412. def handle_potential_breach(user_id, breach_indicators)
  413. # Handle potential data breach
  414. Rails.logger.warn "Potential data breach detected for user #{user_id}: #{breach_indicators}"
  415. end
  416. def check_suspicious_access_patterns(user_id, data_type, action)
  417. # Check for suspicious access patterns
  418. access_key = "access_pattern:#{user_id}:#{data_type}"
  419. access_count = Redis.current.incr(access_key)
  420. Redis.current.expire(access_key, 1.hour.to_i)
  421. if access_count > 50 # Suspicious if more than 50 accesses per hour
  422. handle_suspicious_access(user_id, data_type, action, access_count)
  423. end
  424. end
  425. def handle_suspicious_access(user_id, data_type, action, count)
  426. Rails.logger.warn "Suspicious access pattern: User #{user_id} accessed #{data_type} #{count} times in the last hour"
  427. end
  428. def get_user_profile_data(user_id)
  429. # Get user profile data
  430. User.find_by(id: user_id)&.attributes || {}
  431. end
  432. end
  433. end

app/services/analytics_service.rb

0.0% lines covered

100.0% branches covered

414 relevant lines. 0 lines covered and 414 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Advanced Analytics Service - GA4-like features with GDPR compliance
  2. class AnalyticsService
  3. include AnalyticsHelper
  4. # Real-time analytics
  5. def self.realtime_stats
  6. {
  7. active_users: Pageview.where('visited_at >= ?', 5.minutes.ago).non_bot.distinct.count(:session_id),
  8. current_pageviews: Pageview.where('visited_at >= ?', 1.minute.ago).count,
  9. top_pages_now: Pageview.where('visited_at >= ?', 5.minutes.ago)
  10. .non_bot
  11. .group(:path)
  12. .count(:id)
  13. .sort_by { |_, count| -count }
  14. .first(5)
  15. .to_h,
  16. active_countries: Pageview.where('visited_at >= ?', 5.minutes.ago)
  17. .non_bot
  18. .where.not(country_code: nil)
  19. .group(:country_code)
  20. .count(:id)
  21. .sort_by { |_, count| -count }
  22. .first(3)
  23. .to_h
  24. }
  25. end
  26. # Advanced audience insights
  27. def self.audience_insights(period: :month)
  28. range = period_range(period)
  29. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  30. {
  31. # Demographics
  32. top_countries: pageviews.where.not(country_code: nil)
  33. .group(:country_code)
  34. .count(:id)
  35. .sort_by { |_, count| -count }
  36. .first(10)
  37. .to_h,
  38. # Technology
  39. browsers: pageviews.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
  40. devices: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
  41. operating_systems: pageviews.group(:os).count(:id).sort_by { |_, count| -count }.to_h,
  42. # Behavior
  43. avg_session_duration: pageviews.average(:duration)&.to_i || 0,
  44. bounce_rate: calculate_bounce_rate(pageviews),
  45. pages_per_session: calculate_pages_per_session(pageviews),
  46. # Acquisition
  47. traffic_sources: pageviews.where.not(referrer: [nil, ''])
  48. .group(:referrer)
  49. .count(:id)
  50. .sort_by { |_, count| -count }
  51. .first(10)
  52. .to_h,
  53. # Engagement
  54. engagement_rate: calculate_engagement_rate(pageviews),
  55. return_visitors: pageviews.where(returning_visitor: true).count,
  56. new_visitors: pageviews.where(unique_visitor: true).count
  57. }
  58. end
  59. # Content performance
  60. def self.content_performance(period: :month)
  61. range = period_range(period)
  62. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  63. {
  64. top_posts: pageviews.where.not(post_id: nil)
  65. .group(:post_id)
  66. .count(:id)
  67. .sort_by { |_, count| -count }
  68. .first(10)
  69. .map do |post_id, count|
  70. post = Post.find_by(id: post_id)
  71. {
  72. post: post,
  73. views: count,
  74. unique_views: pageviews.where(post_id: post_id, unique_visitor: true).count,
  75. avg_duration: pageviews.where(post_id: post_id).average(:duration)&.to_i || 0
  76. }
  77. end,
  78. top_pages: pageviews.where.not(page_id: nil)
  79. .group(:page_id)
  80. .count(:id)
  81. .sort_by { |_, count| -count }
  82. .first(10)
  83. .map do |page_id, count|
  84. page_obj = Page.find_by(id: page_id)
  85. {
  86. page: page_obj,
  87. views: count,
  88. unique_views: pageviews.where(page_id: page_id, unique_visitor: true).count,
  89. avg_duration: pageviews.where(page_id: page_id).average(:duration)&.to_i || 0
  90. }
  91. end,
  92. top_paths: pageviews.group(:path)
  93. .count(:id)
  94. .sort_by { |_, count| -count }
  95. .first(10)
  96. .to_h
  97. }
  98. end
  99. # Conversion tracking (custom events)
  100. def self.track_event(event_name, properties = {})
  101. # Create a custom event record
  102. AnalyticsEvent.create!(
  103. event_name: event_name,
  104. properties: properties,
  105. session_id: properties[:session_id] || generate_session_id,
  106. user_id: properties[:user_id],
  107. path: properties[:path] || '/',
  108. tenant: properties[:tenant] || ActsAsTenant.current_tenant || Tenant.first
  109. )
  110. rescue => e
  111. Rails.logger.error "Failed to track event: #{e.message}"
  112. nil
  113. end
  114. # Advanced metrics methods
  115. def self.total_pageviews(period: :month)
  116. range = period_range(period)
  117. Pageview.where(visited_at: range).non_bot.consented_only.count
  118. end
  119. def self.unique_visitors(period: :month)
  120. range = period_range(period)
  121. Pageview.where(visited_at: range).non_bot.consented_only.distinct.count(:session_id)
  122. end
  123. def self.avg_session_duration(period: :month)
  124. range = period_range(period)
  125. avg_duration = Pageview.where(visited_at: range)
  126. .non_bot
  127. .consented_only
  128. .average(:duration)
  129. avg_duration ? avg_duration.to_i : 0
  130. end
  131. def self.bounce_rate(period: :month)
  132. range = period_range(period)
  133. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  134. calculate_bounce_rate(pageviews)
  135. end
  136. def self.pages_per_session(period: :month)
  137. range = period_range(period)
  138. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  139. calculate_pages_per_session(pageviews)
  140. end
  141. def self.traffic_sources(period: :month)
  142. range = period_range(period)
  143. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  144. # Group by referrer and count
  145. referrers = pageviews.where.not(referrer: [nil, ''])
  146. .group(:referrer)
  147. .count(:id)
  148. .sort_by { |_, count| -count }
  149. .first(10)
  150. # Categorize traffic sources
  151. categorized_sources = {
  152. 'Direct' => pageviews.where(referrer: [nil, '']).count,
  153. 'Search' => 0,
  154. 'Social' => 0,
  155. 'Referral' => 0,
  156. 'Email' => 0,
  157. 'Other' => 0
  158. }
  159. referrers.each do |referrer, count|
  160. if referrer.include?('google') || referrer.include?('bing') || referrer.include?('yahoo')
  161. categorized_sources['Search'] += count
  162. elsif referrer.include?('facebook') || referrer.include?('twitter') || referrer.include?('linkedin') || referrer.include?('instagram')
  163. categorized_sources['Social'] += count
  164. elsif referrer.include?('mail') || referrer.include?('email')
  165. categorized_sources['Email'] += count
  166. else
  167. categorized_sources['Referral'] += count
  168. end
  169. end
  170. categorized_sources
  171. end
  172. private
  173. def self.generate_session_id
  174. SecureRandom.hex(16)
  175. end
  176. # Funnel analysis
  177. def self.funnel_analysis(funnel_steps, period: :month)
  178. range = period_range(period)
  179. results = {}
  180. funnel_steps.each_with_index do |step, index|
  181. if index == 0
  182. # First step - all visitors
  183. results[step] = Pageview.where(visited_at: range)
  184. .non_bot
  185. .consented_only
  186. .distinct
  187. .count(:session_id)
  188. else
  189. # Subsequent steps - visitors who completed previous step
  190. previous_step_sessions = results[funnel_steps[index - 1]]
  191. results[step] = Pageview.where(visited_at: range)
  192. .non_bot
  193. .consented_only
  194. .where(session_id: previous_step_sessions)
  195. .distinct
  196. .count(:session_id)
  197. end
  198. end
  199. results
  200. end
  201. # Cohort analysis
  202. def self.cohort_analysis(period: :month)
  203. range = period_range(period)
  204. # Group users by their first visit week
  205. cohorts = Pageview.where(visited_at: range)
  206. .non_bot
  207. .consented_only
  208. .group("DATE_TRUNC('week', visited_at)")
  209. .count(:session_id)
  210. cohorts
  211. end
  212. # AI-powered automated insights
  213. def self.generate_insights(period: :month)
  214. range = period_range(period)
  215. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  216. insights = []
  217. # Advanced traffic growth analysis
  218. current_period_count = pageviews.count
  219. previous_period_count = Pageview.where(visited_at: previous_period_range(period)).non_bot.consented_only.count
  220. if current_period_count > previous_period_count && previous_period_count > 0
  221. growth_percentage = ((current_period_count - previous_period_count).to_f / previous_period_count * 100).round(1)
  222. if growth_percentage > 20
  223. insights << {
  224. type: 'growth',
  225. title: '🚀 Significant Traffic Growth',
  226. message: "Traffic increased by #{growth_percentage}% compared to the previous period",
  227. action: "Analyze your top-performing content and marketing channels to replicate this success",
  228. priority: 'high',
  229. impact: 'positive'
  230. }
  231. elsif growth_percentage > 5
  232. insights << {
  233. type: 'growth',
  234. title: '📈 Steady Growth',
  235. message: "Traffic increased by #{growth_percentage}% compared to the previous period",
  236. action: "Continue your current strategy while experimenting with new content formats",
  237. priority: 'medium',
  238. impact: 'positive'
  239. }
  240. end
  241. elsif current_period_count < previous_period_count && previous_period_count > 0
  242. decline_percentage = ((previous_period_count - current_period_count).to_f / previous_period_count * 100).round(1)
  243. insights << {
  244. type: 'decline',
  245. title: '📉 Traffic Decline Detected',
  246. message: "Traffic decreased by #{decline_percentage}% compared to the previous period",
  247. action: "Review your content strategy and check for technical issues affecting SEO",
  248. priority: 'high',
  249. impact: 'negative'
  250. }
  251. end
  252. # Engagement analysis
  253. avg_engagement = pageviews.where.not(engagement_score: nil).average(:engagement_score) || 0
  254. readers_count = pageviews.where(is_reader: true).count
  255. reader_rate = readers_count > 0 ? (readers_count.to_f / pageviews.count * 100).round(1) : 0
  256. if reader_rate > 40
  257. insights << {
  258. type: 'engagement',
  259. title: '⭐ High Reader Engagement',
  260. message: "#{reader_rate}% of your visitors qualify as readers (30+ seconds)",
  261. action: "Your content is highly engaging! Consider creating more in-depth content",
  262. priority: 'high',
  263. impact: 'positive'
  264. }
  265. elsif reader_rate < 20
  266. insights << {
  267. type: 'engagement',
  268. title: '⚠️ Low Reader Engagement',
  269. message: "Only #{reader_rate}% of visitors are reading your content",
  270. action: "Improve content quality, add visual elements, and optimize for readability",
  271. priority: 'high',
  272. impact: 'negative'
  273. }
  274. end
  275. # Content performance insights
  276. top_posts = pageviews.where.not(post_id: nil)
  277. .group(:post_id)
  278. .count(:id)
  279. .sort_by { |_, count| -count }
  280. .first(3)
  281. if top_posts.any?
  282. top_post = top_posts.first
  283. post = Post.find_by(id: top_post[0])
  284. if post
  285. insights << {
  286. type: 'content',
  287. title: '🏆 Top Performing Content',
  288. message: "#{post.title} is your best performer with #{top_post[1]} views",
  289. action: "Analyze what makes this content successful and create similar pieces",
  290. priority: 'medium',
  291. impact: 'positive'
  292. }
  293. end
  294. end
  295. # Geographic insights
  296. top_countries = pageviews.where.not(country_code: nil)
  297. .group(:country_code)
  298. .count(:id)
  299. .sort_by { |_, count| -count }
  300. .first(3)
  301. if top_countries.any?
  302. top_country = top_countries.first
  303. country_percentage = (top_country[1].to_f / pageviews.count * 100).round(1)
  304. if country_percentage > 40
  305. insights << {
  306. type: 'geography',
  307. title: '🌍 Geographic Concentration',
  308. message: "#{top_country[0]} represents #{country_percentage}% of your traffic",
  309. action: "Consider creating localized content or targeting expansion to new markets",
  310. priority: 'medium',
  311. impact: 'neutral'
  312. }
  313. end
  314. end
  315. # Device and technology insights
  316. mobile_count = pageviews.where(device: 'Mobile').count
  317. mobile_percentage = (mobile_count.to_f / pageviews.count * 100).round(1)
  318. if mobile_percentage > 70
  319. insights << {
  320. type: 'device',
  321. title: '📱 Mobile-First Audience',
  322. message: "#{mobile_percentage}% of your traffic is from mobile devices",
  323. action: "Ensure your site is fully optimized for mobile and consider mobile-specific content",
  324. priority: 'high',
  325. impact: 'positive'
  326. }
  327. elsif mobile_percentage < 30
  328. insights << {
  329. type: 'device',
  330. title: '💻 Desktop-Dominant Traffic',
  331. message: "Only #{mobile_percentage}% of traffic is mobile",
  332. action: "Consider mobile optimization to reach a broader audience",
  333. priority: 'medium',
  334. impact: 'neutral'
  335. }
  336. end
  337. # Traffic source insights
  338. direct_traffic = pageviews.where(referrer: [nil, '']).count
  339. direct_percentage = (direct_traffic.to_f / pageviews.count * 100).round(1)
  340. if direct_percentage > 60
  341. insights << {
  342. type: 'traffic',
  343. title: '🎯 Strong Brand Recognition',
  344. message: "#{direct_percentage}% of your traffic is direct visits",
  345. action: "Your brand awareness is strong! Consider expanding your content marketing",
  346. priority: 'high',
  347. impact: 'positive'
  348. }
  349. end
  350. # Performance insights
  351. slow_pages = pageviews.where('time_on_page < ?', 10).count
  352. slow_percentage = (slow_pages.to_f / pageviews.count * 100).round(1)
  353. if slow_percentage > 50
  354. insights << {
  355. type: 'performance',
  356. title: '⚡ Page Speed Issues',
  357. message: "#{slow_percentage}% of visitors spend less than 10 seconds on your pages",
  358. action: "Optimize page load times and improve content engagement",
  359. priority: 'high',
  360. impact: 'negative'
  361. }
  362. end
  363. # Conversion insights (if conversions are tracked)
  364. conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').count
  365. if conversions > 0
  366. conversion_rate = (conversions.to_f / pageviews.count * 100).round(2)
  367. insights << {
  368. type: 'conversion',
  369. title: '💰 Conversion Performance',
  370. message: "#{conversions} conversions with a #{conversion_rate}% conversion rate",
  371. action: "Analyze your conversion funnel and optimize high-performing pages",
  372. priority: 'high',
  373. impact: 'positive'
  374. }
  375. end
  376. # Sort insights by priority and impact
  377. insights.sort_by do |insight|
  378. priority_score = case insight[:priority]
  379. when 'high' then 3
  380. when 'medium' then 2
  381. when 'low' then 1
  382. else 0
  383. end
  384. impact_score = case insight[:impact]
  385. when 'positive' then 1
  386. when 'negative' then 2
  387. when 'neutral' then 0
  388. else 0
  389. end
  390. -(priority_score + impact_score)
  391. end
  392. end
  393. private
  394. def self.period_range(period)
  395. case period.to_sym
  396. when :today
  397. Time.current.beginning_of_day..Time.current.end_of_day
  398. when :week
  399. 1.week.ago..Time.current
  400. when :month
  401. 1.month.ago..Time.current
  402. when :year
  403. 1.year.ago..Time.current
  404. else
  405. 1.month.ago..Time.current
  406. end
  407. end
  408. def self.previous_period_range(period)
  409. case period.to_sym
  410. when :today
  411. Time.current.beginning_of_day - 1.day..Time.current.end_of_day - 1.day
  412. when :week
  413. 2.weeks.ago..1.week.ago
  414. when :month
  415. 2.months.ago..1.month.ago
  416. when :year
  417. 2.years.ago..1.year.ago
  418. else
  419. 2.months.ago..1.month.ago
  420. end
  421. end
  422. def self.calculate_bounce_rate(pageviews)
  423. total_sessions = pageviews.distinct.count(:session_id)
  424. return 0 if total_sessions.zero?
  425. single_page_sessions = pageviews.group(:session_id)
  426. .having('COUNT(*) = 1')
  427. .count
  428. .size
  429. ((single_page_sessions.to_f / total_sessions) * 100).round(1)
  430. end
  431. def self.calculate_pages_per_session(pageviews)
  432. total_sessions = pageviews.distinct.count(:session_id)
  433. return 0 if total_sessions.zero?
  434. total_pageviews = pageviews.count
  435. (total_pageviews.to_f / total_sessions).round(2)
  436. end
  437. def self.calculate_engagement_rate(pageviews)
  438. total_pageviews = pageviews.count
  439. return 0 if total_pageviews.zero?
  440. engaged_sessions = pageviews.where('duration > ?', 30).count
  441. ((engaged_sessions.to_f / total_pageviews) * 100).round(1)
  442. end
  443. end

app/services/builder_liquid_renderer.rb

0.0% lines covered

100.0% branches covered

716 relevant lines. 0 lines covered and 716 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderLiquidRenderer
  2. attr_reader :builder_theme, :theme_file_manager
  3. def initialize(builder_theme)
  4. @builder_theme = builder_theme
  5. @themes_manager = ThemesManager.new
  6. # Configure Liquid to allow includes and renders
  7. setup_liquid_file_system
  8. end
  9. # Render a template using the builder theme's data
  10. def render_template(template_name, context = {})
  11. # Get rendered file data (template + layout + sections + settings)
  12. rendered_data = builder_theme.get_rendered_file(template_name)
  13. return '<div class="error">Template not found</div>' unless rendered_data
  14. # Get layout content from filesystem
  15. layout_content = rendered_data[:layout_content]
  16. return '<div class="error">Layout not found</div>' unless layout_content
  17. # Render sections based on file settings
  18. sections_html = render_sections_from_rendered_data(rendered_data, context)
  19. # Replace content_for_layout with rendered sections
  20. layout_content = layout_content.gsub('{{ content_for_layout }}', sections_html)
  21. # Render the layout with all sections and settings
  22. render_layout_with_sections(layout_content, context, rendered_data)
  23. end
  24. # Render a specific section using filesystem content + database settings
  25. def render_section(section_id, section_data, context = {})
  26. # Get section content from filesystem (latest developer changes)
  27. section_content = get_section_content(section_data['type'])
  28. return '' unless section_content
  29. # Register custom filters
  30. self.class.register_liquid_filters(builder_theme.id)
  31. # Create liquid template with permissive settings
  32. template = Liquid::Template.parse(section_content, error_mode: :strict)
  33. # Prepare context with settings from database (user customizations)
  34. liquid_context = {
  35. 'section' => {
  36. 'settings' => section_data['settings'] || {},
  37. 'id' => section_id,
  38. 'type' => section_data['type']
  39. }
  40. }.merge(context)
  41. # Add context data based on section schema requirements
  42. context_data = get_section_context(section_data['type'])
  43. context_data.each do |key, value|
  44. liquid_context["@#{key}"] = value
  45. end
  46. # Render section
  47. template.render!(liquid_context)
  48. rescue Liquid::Error => e
  49. Rails.logger.error "Liquid error in section #{section_id}: #{e.message}"
  50. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  51. "<div class='error'>Liquid error in section #{section_id}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  52. rescue => e
  53. Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
  54. "<div class='error'>Error rendering section: #{e.message}</div>"
  55. end
  56. # Render section with content and settings
  57. def render_section_with_content(section, section_content, context = {})
  58. return '' unless section_content
  59. # Register custom filters
  60. self.class.register_liquid_filters(builder_theme.id)
  61. # Create liquid template with permissive settings
  62. template = Liquid::Template.parse(section_content, error_mode: :strict)
  63. # Prepare context with settings from database (user customizations)
  64. liquid_context = {
  65. 'section' => {
  66. 'settings' => section.settings || {},
  67. 'id' => section.section_id,
  68. 'type' => section.section_type
  69. }
  70. }.merge(context)
  71. # Add context data based on section schema requirements
  72. context_data = get_section_context(section.section_type)
  73. context_data.each do |key, value|
  74. liquid_context["@#{key}"] = value
  75. end
  76. # Render section
  77. template.render!(liquid_context)
  78. rescue Liquid::Error => e
  79. Rails.logger.error "Liquid error in section #{section.section_id}: #{e.message}"
  80. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  81. "<div class='error'>Liquid error in section #{section.section_id}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  82. rescue => e
  83. Rails.logger.error "Error rendering section #{section.section_id}: #{e.message}"
  84. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  85. "<div class='error'>Error rendering section: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  86. end
  87. # Get all available template types
  88. def available_templates
  89. template_files = builder_theme.builder_theme_files.templates
  90. template_files.map { |file| file.template_name }.compact
  91. end
  92. # Get all available section types
  93. def available_sections
  94. section_files = builder_theme.builder_theme_files.sections
  95. section_files.map { |file| file.section_name }.compact
  96. end
  97. # Get template data - always from filesystem (latest changes)
  98. def get_template_data(template_type)
  99. @themes_manager.get_parsed_file("templates/#{template_type}.json") || {}
  100. end
  101. # Get section content - from filesystem or PublishedThemeFile
  102. def get_section_content(section_type)
  103. content = get_file_content("sections/#{section_type}.liquid") || ''
  104. # Remove schema tags from section content for rendering
  105. # Schema tags are used by the builder UI, not for rendering
  106. content.gsub(/{%\s*schema\s*%}.*?{%\s*endschema\s*%}/m, '')
  107. end
  108. # Get layout content - from filesystem or PublishedThemeFile
  109. def get_layout_content
  110. get_file_content("layout/theme.liquid") || default_layout
  111. end
  112. # Get theme assets - from filesystem or PublishedThemeFile
  113. def assets
  114. {
  115. css: get_file_content('assets/theme.css') || '',
  116. js: get_file_content('assets/theme.js') || ''
  117. }
  118. end
  119. private
  120. # Get context data for a specific section based on its schema requirements
  121. def get_section_context(section_type)
  122. # Get the section schema to see what context it requests
  123. section_schema = get_section_schema(section_type)
  124. return {} unless section_schema&.dig('context_requests')
  125. context_data = {}
  126. section_schema['context_requests'].each do |key, request_config|
  127. case key
  128. when 'menus'
  129. context_data[key] = get_menus_context
  130. when 'pages'
  131. context_data[key] = get_pages_context
  132. when 'posts'
  133. context_data[key] = get_posts_context
  134. when 'categories'
  135. context_data[key] = get_categories_context
  136. when 'products'
  137. context_data[key] = get_products_context
  138. else
  139. Rails.logger.warn "Unknown context request: #{key}"
  140. end
  141. end
  142. context_data
  143. end
  144. def get_section_schema(section_type)
  145. # Get the section schema from the theme files
  146. theme_name = builder_theme.theme_name
  147. manager = ThemesManager.new
  148. schema_path = File.join(manager.themes_path, theme_name, 'sections', "#{section_type}.json")
  149. return nil unless File.exist?(schema_path)
  150. JSON.parse(File.read(schema_path))
  151. rescue JSON::ParserError, Errno::ENOENT
  152. nil
  153. end
  154. def get_menus_context
  155. # Return available menus for navigation
  156. # In a real system, this would query the database
  157. [
  158. {
  159. id: 1,
  160. name: 'Main Navigation',
  161. menu_items: [
  162. { id: 1, title: 'Home', url: '/', order: 1 },
  163. { id: 2, title: 'About', url: '/about', order: 2 },
  164. { id: 3, title: 'Services', url: '/services', order: 3 },
  165. { id: 4, title: 'Contact', url: '/contact', order: 4 }
  166. ]
  167. },
  168. {
  169. id: 2,
  170. name: 'Footer Links',
  171. menu_items: [
  172. { id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
  173. { id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
  174. { id: 7, title: 'Support', url: '/support', order: 3 }
  175. ]
  176. }
  177. ]
  178. end
  179. def get_pages_context
  180. # Return available pages
  181. # In a real system, this would query the database
  182. [
  183. { id: 1, title: 'Home', slug: 'home', url: '/' },
  184. { id: 2, title: 'About Us', slug: 'about', url: '/about' },
  185. { id: 3, title: 'Services', slug: 'services', url: '/services' },
  186. { id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
  187. { id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
  188. ]
  189. end
  190. def get_posts_context
  191. # Return recent posts
  192. # In a real system, this would query the database
  193. [
  194. { id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
  195. { id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
  196. ]
  197. end
  198. def get_categories_context
  199. # Return post categories
  200. # In a real system, this would query the database
  201. [
  202. { id: 1, name: 'News', slug: 'news' },
  203. { id: 2, name: 'Tutorials', slug: 'tutorials' },
  204. { id: 3, name: 'Updates', slug: 'updates' }
  205. ]
  206. end
  207. def get_products_context
  208. # Return sample products (for e-commerce sections)
  209. # In a real system, this would query the database
  210. [
  211. { id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
  212. { id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
  213. ]
  214. end
  215. def setup_liquid_file_system
  216. # Create a custom file system that can resolve includes from PublishedThemeFile
  217. if @builder_theme.respond_to?(:instance_variable_get) &&
  218. @builder_theme.instance_variable_get(:@published_version)
  219. published_version = @builder_theme.instance_variable_get(:@published_version)
  220. # Use PublishedVersion directly as the file system
  221. Liquid::Template.file_system = published_version
  222. Rails.logger.info "Set up PublishedVersion as Liquid file system"
  223. else
  224. # Use default file system for regular themes
  225. Liquid::Template.file_system = Liquid::LocalFileSystem.new("/", "%s.liquid")
  226. end
  227. end
  228. # Get file content from either PublishedThemeFile or ThemesManager
  229. def get_file_content(file_path)
  230. # Check if we're using PublishedThemeFile (FrontendRendererService)
  231. if @builder_theme.respond_to?(:instance_variable_get) &&
  232. @builder_theme.instance_variable_get(:@published_version)
  233. published_version = @builder_theme.instance_variable_get(:@published_version)
  234. file = published_version.published_theme_files.find_by(file_path: file_path)
  235. return file&.content
  236. end
  237. # Otherwise use ThemesManager
  238. @themes_manager.get_file(file_path)
  239. end
  240. # Get asset content - from filesystem or PublishedThemeFile
  241. def get_asset_content(asset_path)
  242. get_file_content(asset_path) || ''
  243. end
  244. # Get theme settings
  245. def theme_settings
  246. # Return empty hash since BuilderTheme doesn't have settings_data
  247. {}
  248. end
  249. # Render preview - should use real data, not sample data
  250. def render_preview(template_type = 'index')
  251. # This method should not be used - use real context data instead
  252. raise "render_preview should not be used - use render_template with real context data"
  253. end
  254. # Update template data
  255. def update_template_data(template_type, template_data)
  256. content = JSON.pretty_generate(template_data)
  257. builder_theme.update_file("templates/#{template_type}.json", content)
  258. end
  259. # Update section content
  260. def update_section_content(section_type, content)
  261. builder_theme.update_file("sections/#{section_type}.liquid", content)
  262. end
  263. # Update layout content
  264. def update_layout_content(content)
  265. builder_theme.update_file("layout/theme.liquid", content)
  266. end
  267. # Update asset content
  268. def update_asset_content(asset_type, content)
  269. builder_theme.update_file("assets/theme.#{asset_type}", content)
  270. end
  271. # Update theme settings
  272. def update_theme_settings(settings)
  273. # BuilderTheme doesn't have settings_data, so we'll skip this for now
  274. # In the future, this could be stored in a separate settings table
  275. Rails.logger.info "Theme settings update requested: #{settings}"
  276. end
  277. private
  278. def render_layout_with_sections(layout_content, context, rendered_data = {})
  279. # Register custom filters
  280. self.class.register_liquid_filters(builder_theme.id)
  281. # Process section tags first
  282. processed_content = process_section_tags(layout_content, context)
  283. # Parse the layout as a Liquid template with permissive settings
  284. template = Liquid::Template.parse(processed_content, error_mode: :strict)
  285. # Prepare context with all available data
  286. liquid_context = build_liquid_context(context, rendered_data)
  287. # Render the layout
  288. template.render!(liquid_context)
  289. rescue Liquid::Error => e
  290. Rails.logger.error "Liquid error in layout: #{e.message}"
  291. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  292. "<div class='error'>Liquid error in layout: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  293. rescue => e
  294. Rails.logger.error "Error rendering layout: #{e.message}"
  295. "<div class='error'>Error rendering layout: #{e.message}</div>"
  296. end
  297. def process_section_tags(content, context)
  298. # Replace {% section 'section_name' %} with rendered section content
  299. content.gsub(/{%\s*section\s+['"]([^'"]+)['"]\s*%}/) do |match|
  300. section_name = $1
  301. render_section_by_name(section_name, context)
  302. end
  303. end
  304. def render_section_by_name(section_name, context)
  305. section_content = load_section_content(section_name)
  306. return '' unless section_content
  307. # Register custom filters
  308. self.class.register_liquid_filters(builder_theme.id)
  309. # Create liquid template with permissive settings
  310. template = Liquid::Template.parse(section_content, error_mode: :strict)
  311. # Prepare context
  312. liquid_context = build_liquid_context(context)
  313. # Add context data based on section schema requirements
  314. context_data = get_section_context(section_name)
  315. context_data.each do |key, value|
  316. liquid_context["@#{key}"] = value
  317. end
  318. # Render section
  319. template.render!(liquid_context)
  320. rescue Liquid::Error => e
  321. Rails.logger.error "Liquid error in section #{section_name}: #{e.message}"
  322. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  323. "<div class='error'>Liquid error in section #{section_name}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  324. rescue => e
  325. Rails.logger.error "Error rendering section #{section_name}: #{e.message}"
  326. "<div class='error'>Error rendering section #{section_name}: #{e.message}</div>"
  327. end
  328. def load_template_data(template_type)
  329. template_data(template_type)
  330. end
  331. def load_section_content(section_type)
  332. get_section_content(section_type)
  333. end
  334. def load_layout_content
  335. layout_content
  336. end
  337. def asset_content(asset_path)
  338. asset_file = builder_theme.get_file(asset_path)
  339. asset_file&.content || ''
  340. end
  341. def render_sections_from_rendered_data(rendered_data, context)
  342. sections_html = ''
  343. # Get sections from template content with proper order
  344. template_content = rendered_data[:template_content] || {}
  345. sections = template_content['sections'] || {}
  346. section_order = template_content['order'] || sections.keys
  347. # Render sections in the correct order
  348. section_order.each do |section_id|
  349. section_data = sections[section_id]
  350. next unless section_data
  351. Rails.logger.info "Rendering section: #{section_id} (#{section_data['type']}) with settings: #{section_data['settings']}"
  352. # Skip header/footer as they're rendered by the layout
  353. next if %w[header footer].include?(section_data['type'])
  354. # Get section content from filesystem
  355. section_content = get_section_content(section_data['type'])
  356. next unless section_content
  357. # Create a mock section object for compatibility
  358. section = OpenStruct.new(
  359. section_id: section_id,
  360. section_type: section_data['type'],
  361. settings: section_data['settings'] || {}
  362. )
  363. # Render section with its settings
  364. sections_html += render_section_with_content(section, section_content, context)
  365. end
  366. sections_html
  367. rescue => e
  368. Rails.logger.error "Error rendering template sections: #{e.message}"
  369. "<div class='error'>Error rendering template: #{e.message}</div>"
  370. end
  371. def build_liquid_context(context = {}, rendered_data = {})
  372. # Start with minimal base context - no sample data
  373. base_context = {
  374. 'site' => {
  375. 'title' => 'RailsPress Site',
  376. 'description' => 'A RailsPress powered website',
  377. 'url' => 'https://example.com'
  378. },
  379. 'page' => {
  380. 'title' => 'Page Title',
  381. 'url' => '/current-page'
  382. }
  383. }
  384. # Add rendered data context
  385. if rendered_data.present?
  386. base_context.merge!({
  387. 'template_settings' => rendered_data[:template_settings] || {},
  388. 'layout_settings' => rendered_data[:layout_settings] || {},
  389. 'theme_settings' => rendered_data[:theme_settings] || {}
  390. })
  391. end
  392. base_context.merge(context)
  393. end
  394. # Register all Liquid filters and tags
  395. def self.register_liquid_filters(builder_theme_id = nil)
  396. # Create a custom asset filter with the theme ID
  397. custom_asset_filters = Module.new do
  398. define_method :asset_url do |input|
  399. if builder_theme_id
  400. "/admin/builder/#{builder_theme_id}/#{input}"
  401. else
  402. "/assets/#{input}"
  403. end
  404. end
  405. define_method :image_url do |input|
  406. "/images/#{input}"
  407. end
  408. end
  409. Liquid::Template.register_filter(custom_asset_filters)
  410. Liquid::Template.register_filter(ContentFilters)
  411. Liquid::Template.register_filter(DateFilters)
  412. Liquid::Template.register_filter(StringFilters)
  413. Liquid::Template.register_filter(ArrayFilters)
  414. Liquid::Template.register_filter(UrlFilters)
  415. Liquid::Template.register_filter(MetaFilters)
  416. # Register custom tags
  417. Liquid::Template.register_tag('section', SectionTag)
  418. Liquid::Template.register_tag('paginate', PaginateTag)
  419. Liquid::Template.register_tag('form', FormTag)
  420. Liquid::Template.register_tag('comment_form', CommentFormTag)
  421. Liquid::Template.register_tag('search_form', SearchFormTag)
  422. end
  423. # Asset filters
  424. module AssetFilters
  425. def asset_url(input)
  426. # Return builder asset URL for preview
  427. # This will be set by the renderer when registering filters
  428. if @builder_theme_id
  429. "/admin/builder/#{@builder_theme_id}/#{input}"
  430. else
  431. "/assets/#{input}"
  432. end
  433. end
  434. def image_url(input)
  435. # Return a placeholder URL for preview
  436. "/images/#{input}"
  437. end
  438. def file_url(input)
  439. # Return a placeholder URL for preview
  440. "/files/#{input}"
  441. end
  442. def stylesheet_url(input)
  443. "/assets/#{input}.css"
  444. end
  445. def script_url(input)
  446. "/assets/#{input}.js"
  447. end
  448. end
  449. # Content filters
  450. module ContentFilters
  451. def strip_html(input)
  452. return '' unless input
  453. ActionController::Base.helpers.strip_tags(input.to_s)
  454. end
  455. def truncate(input, length = 50, truncate_string = '...')
  456. return '' unless input
  457. input.to_s.length > length ? input.to_s[0, length] + truncate_string : input.to_s
  458. end
  459. def truncatewords(input, words = 15, truncate_string = '...')
  460. return '' unless input
  461. words_array = input.to_s.split
  462. words_array.length > words ? words_array[0, words].join(' ') + truncate_string : input.to_s
  463. end
  464. def strip_newlines(input)
  465. return '' unless input
  466. input.to_s.gsub(/\r?\n/, ' ')
  467. end
  468. def newline_to_br(input)
  469. return '' unless input
  470. input.to_s.gsub(/\r?\n/, '<br>')
  471. end
  472. def escape_html(input)
  473. return '' unless input
  474. ERB::Util.html_escape(input.to_s)
  475. end
  476. def unescape_html(input)
  477. return '' unless input
  478. CGI.unescapeHTML(input.to_s)
  479. end
  480. def json(input)
  481. return '{}' unless input
  482. input.to_json
  483. end
  484. def xml_escape(input)
  485. return '' unless input
  486. input.to_s.gsub(/&/, '&amp;').gsub(/</, '&lt;').gsub(/>/, '&gt;').gsub(/"/, '&quot;').gsub(/'/, '&#39;')
  487. end
  488. end
  489. # Date filters
  490. module DateFilters
  491. def date(input, format = '%B %d, %Y')
  492. return '' unless input
  493. begin
  494. date = case input
  495. when String
  496. Time.parse(input)
  497. when Date, Time, DateTime
  498. input
  499. else
  500. input.to_time
  501. end
  502. date.strftime(format)
  503. rescue
  504. input.to_s
  505. end
  506. end
  507. def time(input, format = '%I:%M %p')
  508. date(input, format)
  509. end
  510. def datetime(input, format = '%B %d, %Y at %I:%M %p')
  511. date(input, format)
  512. end
  513. def time_ago(input)
  514. return '' unless input
  515. begin
  516. time = case input
  517. when String
  518. Time.parse(input)
  519. when Date, Time, DateTime
  520. input
  521. else
  522. input.to_time
  523. end
  524. time_ago_in_words(time)
  525. rescue
  526. input.to_s
  527. end
  528. end
  529. def time_ago_in_words(time)
  530. distance = Time.current - time
  531. case distance
  532. when 0..1.minute
  533. 'just now'
  534. when 1.minute..1.hour
  535. "#{(distance / 1.minute).round} minutes ago"
  536. when 1.hour..1.day
  537. "#{(distance / 1.hour).round} hours ago"
  538. when 1.day..1.week
  539. "#{(distance / 1.day).round} days ago"
  540. when 1.week..1.month
  541. "#{(distance / 1.week).round} weeks ago"
  542. when 1.month..1.year
  543. "#{(distance / 1.month).round} months ago"
  544. else
  545. "#{(distance / 1.year).round} years ago"
  546. end
  547. end
  548. end
  549. # String filters
  550. module StringFilters
  551. def capitalize(input)
  552. return '' unless input
  553. input.to_s.capitalize
  554. end
  555. def upcase(input)
  556. return '' unless input
  557. input.to_s.upcase
  558. end
  559. def downcase(input)
  560. return '' unless input
  561. input.to_s.downcase
  562. end
  563. def capitalize_words(input)
  564. return '' unless input
  565. input.to_s.split.map(&:capitalize).join(' ')
  566. end
  567. def replace(input, string, replacement = '')
  568. return '' unless input
  569. input.to_s.gsub(string, replacement)
  570. end
  571. def remove(input, string)
  572. return '' unless input
  573. input.to_s.gsub(string, '')
  574. end
  575. def append(input, string)
  576. return string unless input
  577. input.to_s + string.to_s
  578. end
  579. def prepend(input, string)
  580. return input unless string
  581. string.to_s + input.to_s
  582. end
  583. def slice(input, start, length = 1)
  584. return '' unless input
  585. input.to_s[start.to_i, length.to_i] || ''
  586. end
  587. def size(input)
  588. return 0 unless input
  589. input.to_s.length
  590. end
  591. def lstrip(input)
  592. return '' unless input
  593. input.to_s.lstrip
  594. end
  595. def rstrip(input)
  596. return '' unless input
  597. input.to_s.rstrip
  598. end
  599. def strip(input)
  600. return '' unless input
  601. input.to_s.strip
  602. end
  603. end
  604. # Array filters
  605. module ArrayFilters
  606. def join(input, glue = ' ')
  607. return '' unless input
  608. Array(input).join(glue)
  609. end
  610. def first(input)
  611. return '' unless input
  612. Array(input).first
  613. end
  614. def last(input)
  615. return '' unless input
  616. Array(input).last
  617. end
  618. def size(input)
  619. return 0 unless input
  620. Array(input).size
  621. end
  622. def sort(input, property = nil)
  623. return [] unless input
  624. array = Array(input)
  625. if property
  626. array.sort_by { |item| item.respond_to?(property) ? item.send(property) : item }
  627. else
  628. array.sort
  629. end
  630. end
  631. def reverse(input)
  632. return [] unless input
  633. Array(input).reverse
  634. end
  635. def uniq(input)
  636. return [] unless input
  637. Array(input).uniq
  638. end
  639. def where(input, property, value)
  640. return [] unless input
  641. Array(input).select { |item| item.respond_to?(property) && item.send(property) == value }
  642. end
  643. def where_not(input, property, value)
  644. return [] unless input
  645. Array(input).reject { |item| item.respond_to?(property) && item.send(property) == value }
  646. end
  647. def limit(input, count)
  648. return [] unless input
  649. Array(input).first(count.to_i)
  650. end
  651. def offset(input, count)
  652. return [] unless input
  653. Array(input).drop(count.to_i)
  654. end
  655. end
  656. # URL filters
  657. module UrlFilters
  658. def url_encode(input)
  659. return '' unless input
  660. ERB::Util.url_encode(input.to_s)
  661. end
  662. def url_decode(input)
  663. return '' unless input
  664. CGI.unescape(input.to_s)
  665. end
  666. def link_to(text, url, options = {})
  667. return '' unless text && url
  668. attributes = options.map { |k, v| "#{k}=\"#{v}\"" }.join(' ')
  669. "<a href=\"#{url}\" #{attributes}>#{text}</a>"
  670. end
  671. def link_to_if(condition, text, url, options = {})
  672. return text unless condition
  673. link_to(text, url, options)
  674. end
  675. def link_to_unless(condition, text, url, options = {})
  676. return text if condition
  677. link_to(text, url, options)
  678. end
  679. end
  680. # Meta filters
  681. module MetaFilters
  682. def meta(input, key)
  683. return '' unless input
  684. if input.respond_to?(:meta) && input.meta.is_a?(Hash)
  685. input.meta[key.to_s] || ''
  686. else
  687. ''
  688. end
  689. end
  690. def has_meta(input, key)
  691. return false unless input
  692. if input.respond_to?(:meta) && input.meta.is_a?(Hash)
  693. input.meta.key?(key.to_s)
  694. else
  695. false
  696. end
  697. end
  698. def meta_keys(input)
  699. return [] unless input
  700. if input.respond_to?(:meta) && input.meta.is_a?(Hash)
  701. input.meta.keys
  702. else
  703. []
  704. end
  705. end
  706. end
  707. # Custom Liquid tags
  708. class SectionTag < Liquid::Tag
  709. def initialize(tag_name, markup, options)
  710. super
  711. @section_name = markup.strip.gsub(/['"]/, '')
  712. end
  713. def render(context)
  714. # This would be handled by the main renderer
  715. "<!-- Section: #{@section_name} -->"
  716. end
  717. end
  718. class PaginateTag < Liquid::Tag
  719. def initialize(tag_name, markup, options)
  720. super
  721. @markup = markup.strip
  722. end
  723. def render(context)
  724. paginate = context['paginate']
  725. return '' unless paginate
  726. html = []
  727. html << "<div class=\"pagination\">"
  728. if paginate['current_page'] > 1
  729. html << "<a href=\"?page=#{paginate['current_page'] - 1}\" class=\"prev\">Previous</a>"
  730. end
  731. (1..paginate['total_pages']).each do |page|
  732. if page == paginate['current_page']
  733. html << "<span class=\"current\">#{page}</span>"
  734. else
  735. html << "<a href=\"?page=#{page}\" class=\"page\">#{page}</a>"
  736. end
  737. end
  738. if paginate['current_page'] < paginate['total_pages']
  739. html << "<a href=\"?page=#{paginate['current_page'] + 1}\" class=\"next\">Next</a>"
  740. end
  741. html << "</div>"
  742. html.join("\n")
  743. end
  744. end
  745. class FormTag < Liquid::Tag
  746. def initialize(tag_name, markup, options)
  747. super
  748. @markup = markup.strip
  749. end
  750. def render(context)
  751. # Basic form rendering
  752. "<form method=\"post\" class=\"liquid-form\">#{@markup}</form>"
  753. end
  754. end
  755. class CommentFormTag < Liquid::Tag
  756. def render(context)
  757. post = context['post']
  758. return '' unless post
  759. html = []
  760. html << "<form method=\"post\" action=\"/comments\" class=\"comment-form\">"
  761. html << "<input type=\"hidden\" name=\"post_id\" value=\"#{post.id}\">"
  762. html << "<div class=\"form-group\">"
  763. html << "<label for=\"author_name\">Name:</label>"
  764. html << "<input type=\"text\" name=\"author_name\" id=\"author_name\" required>"
  765. html << "</div>"
  766. html << "<div class=\"form-group\">"
  767. html << "<label for=\"author_email\">Email:</label>"
  768. html << "<input type=\"email\" name=\"author_email\" id=\"author_email\" required>"
  769. html << "</div>"
  770. html << "<div class=\"form-group\">"
  771. html << "<label for=\"content\">Comment:</label>"
  772. html << "<textarea name=\"content\" id=\"content\" required></textarea>"
  773. html << "</div>"
  774. html << "<button type=\"submit\">Submit Comment</button>"
  775. html << "</form>"
  776. html.join("\n")
  777. end
  778. end
  779. class SearchFormTag < Liquid::Tag
  780. def render(context)
  781. html = []
  782. html << "<form method=\"get\" action=\"/search\" class=\"search-form\">"
  783. html << "<input type=\"text\" name=\"q\" placeholder=\"Search...\" value=\"#{context['search_query'] || ''}\">"
  784. html << "<button type=\"submit\">Search</button>"
  785. html << "</form>"
  786. html.join("\n")
  787. end
  788. end
  789. def default_layout
  790. <<~LIQUID
  791. <!DOCTYPE html>
  792. <html lang="en">
  793. <head>
  794. <meta charset="UTF-8">
  795. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  796. <title>{{ page.title | default: site.title }}</title>
  797. <meta name="description" content="{{ page.description | default: site.description }}">
  798. <style>
  799. {{ assets.css }}
  800. </style>
  801. </head>
  802. <body>
  803. {{ content_for_layout }}
  804. <script>
  805. {{ assets.js }}
  806. </script>
  807. </body>
  808. </html>
  809. LIQUID
  810. end
  811. end

app/services/builder_theme_service.rb

0.0% lines covered

100.0% branches covered

155 relevant lines. 0 lines covered and 155 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class BuilderThemeService
  2. attr_reader :builder_theme
  3. def initialize(builder_theme)
  4. @builder_theme = builder_theme
  5. end
  6. # Apply theme snapshot to the frontend
  7. def apply_snapshot_to_frontend
  8. return false unless builder_theme.published?
  9. snapshot = builder_theme.builder_theme_snapshots.last
  10. return false unless snapshot
  11. # Update the active theme to use this snapshot
  12. update_active_theme_settings(snapshot)
  13. # Clear any relevant caches
  14. clear_theme_caches
  15. # Trigger frontend update notification
  16. notify_frontend_update
  17. true
  18. end
  19. # Create a new version from an existing theme
  20. def create_version_from_theme(theme_name, user, label = nil)
  21. # Get the current published version or create from base theme
  22. base_version = BuilderTheme.current_for_theme(theme_name)
  23. new_version = BuilderTheme.create_version(
  24. theme_name,
  25. user,
  26. base_version,
  27. label
  28. )
  29. # Copy files from base version or theme directory
  30. if base_version
  31. copy_files_from_version(base_version, new_version)
  32. else
  33. copy_files_from_theme_directory(theme_name, new_version)
  34. end
  35. new_version
  36. end
  37. # Export theme as a downloadable package
  38. def export_theme_package
  39. return nil unless builder_theme.published?
  40. # Create a temporary directory for the export
  41. temp_dir = Rails.root.join('tmp', 'theme_exports', "theme_#{builder_theme.id}_#{Time.current.to_i}")
  42. FileUtils.mkdir_p(temp_dir)
  43. begin
  44. # Copy all theme files
  45. builder_theme.builder_theme_files.each do |file|
  46. file_path = temp_dir.join(file.path)
  47. FileUtils.mkdir_p(file_path.dirname)
  48. File.write(file_path, file.content)
  49. end
  50. # Create theme.json with metadata
  51. theme_json = {
  52. name: builder_theme.theme_name,
  53. version: builder_theme.version_number.to_s,
  54. description: "Exported from RailsPress Theme Builder",
  55. author: builder_theme.user.email,
  56. created_at: builder_theme.created_at.iso8601,
  57. files: builder_theme.builder_theme_files.pluck(:path)
  58. }
  59. File.write(temp_dir.join('theme.json'), JSON.pretty_generate(theme_json))
  60. # Create zip file
  61. zip_path = temp_dir.parent.join("#{builder_theme.theme_name}_v#{builder_theme.version_number}.zip")
  62. system("cd #{temp_dir} && zip -r #{zip_path} .")
  63. zip_path if File.exist?(zip_path)
  64. ensure
  65. # Clean up temporary directory
  66. FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
  67. end
  68. end
  69. # Import theme from uploaded package
  70. def self.import_theme_package(zip_file, user, theme_name = nil)
  71. temp_dir = Rails.root.join('tmp', 'theme_imports', "import_#{Time.current.to_i}")
  72. FileUtils.mkdir_p(temp_dir)
  73. begin
  74. # Extract zip file
  75. system("unzip -q #{zip_file.path} -d #{temp_dir}")
  76. # Read theme metadata
  77. theme_json_path = temp_dir.join('theme.json')
  78. if File.exist?(theme_json_path)
  79. theme_data = JSON.parse(File.read(theme_json_path))
  80. theme_name ||= theme_data['name']
  81. end
  82. # Create new builder theme version
  83. builder_theme = BuilderTheme.create_version(theme_name, user, nil, "Imported theme")
  84. # Copy files to builder theme
  85. copy_files_from_directory(temp_dir, builder_theme)
  86. builder_theme
  87. ensure
  88. FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
  89. end
  90. end
  91. # Validate theme structure
  92. def validate_theme_structure
  93. errors = []
  94. # Check for required files
  95. required_files = ['templates/index.json', 'layout/theme.liquid']
  96. required_files.each do |file|
  97. unless builder_theme.get_file(file)
  98. errors << "Missing required file: #{file}"
  99. end
  100. end
  101. # Validate JSON files
  102. builder_theme.builder_theme_files.json_files.each do |file|
  103. begin
  104. JSON.parse(file.content)
  105. rescue JSON::ParserError => e
  106. errors << "Invalid JSON in #{file.path}: #{e.message}"
  107. end
  108. end
  109. # Validate Liquid files
  110. builder_theme.builder_theme_files.liquid_files.each do |file|
  111. # Basic Liquid syntax validation could be added here
  112. # For now, we'll just check that the file isn't empty
  113. if file.content.strip.empty?
  114. errors << "Empty Liquid file: #{file.path}"
  115. end
  116. end
  117. errors
  118. end
  119. private
  120. def update_active_theme_settings(snapshot)
  121. # Update the active theme's settings in the database
  122. active_theme = Theme.active.first
  123. return unless active_theme
  124. # Merge snapshot settings with existing theme settings
  125. current_settings = active_theme.settings || {}
  126. snapshot_settings = snapshot.settings
  127. # Update theme settings
  128. active_theme.update!(settings: current_settings.merge(snapshot_settings))
  129. # Store snapshot reference for rollback capability
  130. Rails.cache.write("active_theme_snapshot_#{active_theme.name}", snapshot.id, expires_in: 1.week)
  131. end
  132. def clear_theme_caches
  133. # Clear Rails view cache
  134. ActionView::LookupContext::DetailsKey.clear
  135. # Clear any custom theme caches
  136. Rails.cache.delete_matched("theme_*")
  137. # Clear asset cache if using asset pipeline
  138. Rails.application.config.assets.version = Time.current.to_i.to_s if Rails.application.config.respond_to?(:assets)
  139. end
  140. def notify_frontend_update
  141. # Broadcast to any connected frontend clients
  142. ActionCable.server.broadcast(
  143. 'theme_updates',
  144. {
  145. type: 'theme_updated',
  146. theme_name: builder_theme.theme_name,
  147. timestamp: Time.current.to_i
  148. }
  149. )
  150. end
  151. def copy_files_from_version(source_version, target_version)
  152. source_version.builder_theme_files.each do |file|
  153. target_version.builder_theme_files.create!(
  154. path: file.path,
  155. content: file.content,
  156. checksum: file.checksum,
  157. file_size: file.file_size
  158. )
  159. end
  160. end
  161. def copy_files_from_theme_directory(theme_name, builder_theme)
  162. theme_path = Rails.root.join('app', 'themes', theme_name)
  163. return unless Dir.exist?(theme_path)
  164. copy_files_recursive(theme_path, builder_theme, '')
  165. end
  166. def copy_files_recursive(directory, builder_theme, relative_path)
  167. Dir.entries(directory).each do |entry|
  168. next if entry.start_with?('.')
  169. entry_path = File.join(directory, entry)
  170. file_relative_path = relative_path.present? ? "#{relative_path}/#{entry}" : entry
  171. if File.directory?(entry_path)
  172. copy_files_recursive(entry_path, builder_theme, file_relative_path)
  173. else
  174. content = File.read(entry_path)
  175. builder_theme.update_file(file_relative_path, content)
  176. end
  177. end
  178. end
  179. def self.copy_files_from_directory(directory, builder_theme)
  180. Dir.glob(File.join(directory, '**', '*')).each do |file_path|
  181. next if File.directory?(file_path)
  182. relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(directory)).to_s
  183. content = File.read(file_path)
  184. builder_theme.update_file(relative_path, content)
  185. end
  186. end
  187. end

app/services/content_analytics_service.rb

0.0% lines covered

100.0% branches covered

239 relevant lines. 0 lines covered and 239 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Content Analytics Service - Medium-like analytics for posts and pages
  2. class ContentAnalyticsService
  3. include AnalyticsHelper
  4. # Get comprehensive analytics for a specific post
  5. def self.post_analytics(post_id, period: :month)
  6. post = Post.find(post_id)
  7. range = period_range(period)
  8. pageviews = Pageview.where(visited_at: range, post_id: post_id).non_bot.consented_only
  9. readers = pageviews.where(is_reader: true) # Medium-like readers (30+ seconds)
  10. {
  11. # Basic metrics
  12. total_views: pageviews.count,
  13. unique_readers: pageviews.distinct.count(:session_id),
  14. medium_readers: readers.count, # Users who spent 30+ seconds (Medium definition)
  15. reader_conversion_rate: readers.count.to_f / [pageviews.count, 1].max * 100,
  16. returning_readers: pageviews.where(returning_visitor: true).distinct.count(:session_id),
  17. # Engagement metrics
  18. avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  19. avg_engagement_score: pageviews.where.not(engagement_score: nil).average(:engagement_score)&.to_f || 0.0,
  20. avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
  21. avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  22. avg_time_on_page: pageviews.where.not(time_on_page: nil).average(:time_on_page)&.to_i || 0,
  23. # Reader behavior (Medium-like)
  24. readers_who_scrolled_to_bottom: readers.where(scroll_depth: 100).count,
  25. readers_who_spent_time: readers.where('time_on_page > ?', 30).count,
  26. readers_with_exit_intent: readers.where(exit_intent: true).count,
  27. # Demographics (focus on actual readers)
  28. readers_by_country: readers.where.not(country_code: nil)
  29. .group(:country_code)
  30. .count(:id)
  31. .sort_by { |_, count| -count }
  32. .first(10)
  33. .to_h,
  34. readers_by_device: readers.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
  35. readers_by_browser: readers.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
  36. # Traffic sources
  37. traffic_sources: pageviews.where.not(referrer: [nil, ''])
  38. .group(:referrer)
  39. .count(:id)
  40. .sort_by { |_, count| -count }
  41. .first(10)
  42. .to_h,
  43. # Time-based analytics
  44. views_by_hour: pageviews.group("strftime('%H', visited_at)")
  45. .count(:id)
  46. .transform_keys(&:to_i)
  47. .sort.to_h,
  48. views_by_day: pageviews.group("date(visited_at)")
  49. .count(:id)
  50. .sort_by { |date, _| Date.parse(date) }
  51. .to_h,
  52. # Content performance
  53. reading_time_estimate: estimate_reading_time(post),
  54. engagement_score: calculate_engagement_score(pageviews),
  55. # Post metadata
  56. post: {
  57. id: post.id,
  58. title: post.title,
  59. slug: post.slug,
  60. published_at: post.published_at,
  61. word_count: post.word_count,
  62. reading_time: post.reading_time
  63. }
  64. }
  65. end
  66. # Get comprehensive analytics for a specific page
  67. def self.page_analytics(page_id, period: :month)
  68. page = Page.find(page_id)
  69. range = period_range(period)
  70. pageviews = Pageview.where(visited_at: range, page_id: page_id).non_bot.consented_only
  71. {
  72. # Basic metrics
  73. total_views: pageviews.count,
  74. unique_visitors: pageviews.distinct.count(:session_id),
  75. returning_visitors: pageviews.where(returning_visitor: true).distinct.count(:session_id),
  76. # Engagement metrics
  77. avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  78. avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
  79. avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  80. avg_time_on_page: pageviews.where.not(time_on_page: nil).average(:time_on_page)&.to_i || 0,
  81. # Visitor behavior
  82. visitors_who_scrolled_to_bottom: pageviews.where(scroll_depth: 100).count,
  83. visitors_who_spent_time: pageviews.where('time_on_page > ?', 30).count,
  84. visitors_with_exit_intent: pageviews.where(exit_intent: true).count,
  85. # Demographics
  86. visitors_by_country: pageviews.where.not(country_code: nil)
  87. .group(:country_code)
  88. .count(:id)
  89. .sort_by { |_, count| -count }
  90. .first(10)
  91. .to_h,
  92. visitors_by_device: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
  93. visitors_by_browser: pageviews.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
  94. # Traffic sources
  95. traffic_sources: pageviews.where.not(referrer: [nil, ''])
  96. .group(:referrer)
  97. .count(:id)
  98. .sort_by { |_, count| -count }
  99. .first(10)
  100. .to_h,
  101. # Time-based analytics
  102. views_by_hour: pageviews.group("strftime('%H', visited_at)")
  103. .count(:id)
  104. .transform_keys(&:to_i)
  105. .sort.to_h,
  106. views_by_day: pageviews.group("date(visited_at)")
  107. .count(:id)
  108. .sort_by { |date, _| Date.parse(date) }
  109. .to_h,
  110. # Content performance
  111. engagement_score: calculate_engagement_score(pageviews),
  112. # Page metadata
  113. page: {
  114. id: page.id,
  115. title: page.title,
  116. slug: page.slug,
  117. published_at: page.published_at,
  118. word_count: page.word_count
  119. }
  120. }
  121. end
  122. # Get top performing content
  123. def self.top_performing_content(period: :month, limit: 10)
  124. range = period_range(period)
  125. # Top posts
  126. top_posts = Pageview.where(visited_at: range)
  127. .where.not(post_id: nil)
  128. .non_bot
  129. .consented_only
  130. .group(:post_id)
  131. .count(:id)
  132. .sort_by { |_, count| -count }
  133. .first(limit)
  134. .map do |post_id, views|
  135. post = Post.find_by(id: post_id)
  136. next unless post
  137. post_pageviews = Pageview.where(visited_at: range, post_id: post_id).non_bot.consented_only
  138. {
  139. id: post.id,
  140. title: post.title,
  141. slug: post.slug,
  142. published_at: post.published_at,
  143. views: views,
  144. unique_readers: post_pageviews.distinct.count(:session_id),
  145. avg_reading_time: post_pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  146. avg_completion_rate: post_pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  147. engagement_score: calculate_engagement_score(post_pageviews),
  148. url: Rails.application.routes.url_helpers.post_path(post)
  149. }
  150. end.compact
  151. # Top pages
  152. top_pages = Pageview.where(visited_at: range)
  153. .where.not(page_id: nil)
  154. .non_bot
  155. .consented_only
  156. .group(:page_id)
  157. .count(:id)
  158. .sort_by { |_, count| -count }
  159. .first(limit)
  160. .map do |page_id, views|
  161. page = Page.find_by(id: page_id)
  162. next unless page
  163. page_pageviews = Pageview.where(visited_at: range, page_id: page_id).non_bot.consented_only
  164. {
  165. id: page.id,
  166. title: page.title,
  167. slug: page.slug,
  168. published_at: page.published_at,
  169. views: views,
  170. unique_visitors: page_pageviews.distinct.count(:session_id),
  171. avg_reading_time: page_pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  172. avg_completion_rate: page_pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  173. engagement_score: calculate_engagement_score(page_pageviews),
  174. url: Rails.application.routes.url_helpers.page_path(page)
  175. }
  176. end.compact
  177. {
  178. top_posts: top_posts,
  179. top_pages: top_pages,
  180. period: period,
  181. generated_at: Time.current
  182. }
  183. end
  184. # Get reader engagement insights
  185. def self.reader_engagement_insights(period: :month)
  186. range = period_range(period)
  187. pageviews = Pageview.where(visited_at: range).non_bot.consented_only
  188. {
  189. # Reading behavior
  190. avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
  191. avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
  192. avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
  193. # Reader segments
  194. quick_readers: pageviews.where('reading_time < ?', 30).count,
  195. engaged_readers: pageviews.where('reading_time BETWEEN ? AND ?', 30, 300).count,
  196. deep_readers: pageviews.where('reading_time > ?', 300).count,
  197. # Engagement levels
  198. low_engagement: pageviews.where('completion_rate < ?', 0.25).count,
  199. medium_engagement: pageviews.where('completion_rate BETWEEN ? AND ?', 0.25, 0.75).count,
  200. high_engagement: pageviews.where('completion_rate > ?', 0.75).count,
  201. # Scroll behavior
  202. readers_who_scrolled_25: pageviews.where('scroll_depth >= ?', 25).count,
  203. readers_who_scrolled_50: pageviews.where('scroll_depth >= ?', 50).count,
  204. readers_who_scrolled_75: pageviews.where('scroll_depth >= ?', 75).count,
  205. readers_who_scrolled_100: pageviews.where('scroll_depth >= ?', 100).count,
  206. # Time patterns
  207. peak_reading_hours: pageviews.group("strftime('%H', visited_at)")
  208. .count(:id)
  209. .sort_by { |_, count| -count }
  210. .first(5)
  211. .to_h,
  212. # Content preferences
  213. preferred_content_length: analyze_content_length_preferences(pageviews),
  214. preferred_device_types: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h
  215. }
  216. end
  217. private
  218. def self.period_range(period)
  219. case period.to_sym
  220. when :today
  221. Time.current.beginning_of_day..Time.current.end_of_day
  222. when :week
  223. 1.week.ago..Time.current
  224. when :month
  225. 1.month.ago..Time.current
  226. when :year
  227. 1.year.ago..Time.current
  228. else
  229. 1.month.ago..Time.current
  230. end
  231. end
  232. def self.estimate_reading_time(content)
  233. return 0 unless content.respond_to?(:content)
  234. # Estimate reading time based on word count (average 200 words per minute)
  235. word_count = content.content&.gsub(/<[^>]*>/, '')&.split&.count || 0
  236. (word_count / 200.0).ceil
  237. end
  238. def self.calculate_engagement_score(pageviews)
  239. return 0 if pageviews.empty?
  240. # Calculate engagement score based on multiple factors
  241. avg_completion = pageviews.where.not(completion_rate: nil).average(:completion_rate) || 0
  242. avg_scroll_depth = pageviews.where.not(scroll_depth: nil).average(:scroll_depth) || 0
  243. avg_time_on_page = pageviews.where.not(time_on_page: nil).average(:time_on_page) || 0
  244. # Weighted score: completion rate (40%), scroll depth (30%), time on page (30%)
  245. engagement_score = (avg_completion * 0.4) + (avg_scroll_depth / 100.0 * 0.3) + (avg_time_on_page / 300.0 * 0.3)
  246. # Normalize to 0-100 scale
  247. (engagement_score * 100).round(1)
  248. end
  249. def self.analyze_content_length_preferences(pageviews)
  250. # Analyze what content lengths perform best
  251. content_performance = {}
  252. pageviews.includes(:post, :page).each do |pageview|
  253. content = pageview.post || pageview.page
  254. next unless content
  255. word_count = content.content&.gsub(/<[^>]*>/, '')&.split&.count || 0
  256. length_category = case word_count
  257. when 0..500 then 'short'
  258. when 501..1500 then 'medium'
  259. when 1501..3000 then 'long'
  260. else 'very_long'
  261. end
  262. content_performance[length_category] ||= { views: 0, engagement: 0 }
  263. content_performance[length_category][:views] += 1
  264. content_performance[length_category][:engagement] += pageview.completion_rate || 0
  265. end
  266. # Calculate average engagement per category
  267. content_performance.transform_values do |data|
  268. {
  269. views: data[:views],
  270. avg_engagement: data[:engagement] / data[:views].to_f
  271. }
  272. end
  273. end
  274. end

app/services/documentation_sync_service.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class DocumentationSyncService
  2. include ActiveModel::Model
  3. attr_accessor :source_url, :force_update
  4. def initialize(source_url: nil, force_update: false)
  5. @source_url = source_url || Rails.application.routes.url_helpers.root_url
  6. @force_update = force_update
  7. end
  8. # Sync documentation from external source
  9. def sync_from_source
  10. return false unless source_url.present?
  11. begin
  12. # Fetch documentation from source
  13. response = HTTParty.get("#{source_url}/api/documentation", timeout: 30)
  14. return false unless response.success?
  15. docs_data = response.parsed_response
  16. # Update theme documentation
  17. if docs_data['theme_development_docs'].present?
  18. update_site_setting('theme_development_docs', docs_data['theme_development_docs'])
  19. end
  20. # Update plugin documentation
  21. if docs_data['plugin_development_docs'].present?
  22. update_site_setting('plugin_development_docs', docs_data['plugin_development_docs'])
  23. end
  24. # Update sync timestamp
  25. update_site_setting('docs_last_synced_at', Time.current)
  26. Rails.logger.info "Documentation synced successfully from #{source_url}"
  27. true
  28. rescue => e
  29. Rails.logger.error "Failed to sync documentation: #{e.message}"
  30. false
  31. end
  32. end
  33. # Check if sync is needed
  34. def sync_needed?
  35. return true if force_update
  36. last_sync = get_site_setting('docs_last_synced_at')
  37. return true if last_sync.nil?
  38. # Sync if older than 24 hours
  39. last_sync < 24.hours.ago
  40. end
  41. # Auto-sync if needed
  42. def auto_sync
  43. return false unless sync_needed?
  44. sync_from_source
  45. end
  46. private
  47. def update_site_setting(key, value)
  48. SiteSetting.set(key, value, 'text')
  49. end
  50. def get_site_setting(key)
  51. SiteSetting.get(key)
  52. end
  53. end

app/services/frontend_renderer_service.rb

0.0% lines covered

100.0% branches covered

110 relevant lines. 0 lines covered and 110 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class FrontendRendererService
  2. attr_reader :published_version, :builder_renderer
  3. def initialize(published_version, builder_theme_id = nil)
  4. @published_version = published_version
  5. @builder_theme_id = builder_theme_id
  6. # Create a mock BuilderTheme for the existing BuilderLiquidRenderer
  7. @builder_theme = create_mock_builder_theme
  8. Rails.logger.info "Created mock builder theme: #{@builder_theme.class}"
  9. @builder_renderer = BuilderLiquidRenderer.new(@builder_theme)
  10. Rails.logger.info "Created BuilderLiquidRenderer"
  11. end
  12. # Render a template with all sections, header, footer, etc.
  13. def render_template(template_name, context = {})
  14. # Use the existing BuilderLiquidRenderer
  15. html = @builder_renderer.render_template(template_name, context)
  16. # Replace asset URLs with embedded content for preview
  17. html = replace_asset_urls_with_content(html)
  18. html
  19. rescue => e
  20. Rails.logger.error "FrontendRendererService error: #{e.message}"
  21. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  22. "<div class='error'>FrontendRendererService Error: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  23. end
  24. # Get CSS and JS assets including all sections
  25. def assets
  26. # Use the existing BuilderLiquidRenderer's assets method
  27. @builder_renderer.assets
  28. end
  29. private
  30. def replace_asset_urls_with_content(html)
  31. # Get assets from the renderer
  32. assets = @builder_renderer.assets
  33. # Replace CSS link tags with embedded styles
  34. html = html.gsub(/<link[^>]*href="[^"]*\/theme\.css"[^>]*>/) do |match|
  35. if assets[:css].present?
  36. "<style>#{assets[:css]}</style>"
  37. else
  38. match # Keep original if no CSS
  39. end
  40. end
  41. # Replace JS script tags with embedded scripts
  42. html = html.gsub(/<script[^>]*src="[^"]*\/theme\.js"[^>]*><\/script>/) do |match|
  43. if assets[:js].present?
  44. "<script>#{assets[:js]}</script>"
  45. else
  46. match # Keep original if no JS
  47. end
  48. end
  49. html
  50. rescue => e
  51. Rails.logger.error "Error in replace_asset_urls_with_content: #{e.message}"
  52. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  53. html # Return original HTML if there's an error
  54. end
  55. def create_mock_builder_theme
  56. # Create a mock BuilderTheme object that delegates to PublishedThemeFile
  57. mock_theme = Object.new
  58. # Define methods that BuilderLiquidRenderer expects
  59. def mock_theme.get_rendered_file(template_name)
  60. # Return the template data from PublishedThemeFile
  61. template_file = @published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
  62. return nil unless template_file
  63. template_content = JSON.parse(template_file.content)
  64. # Get layout file
  65. layout_file = @published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
  66. layout_content = layout_file&.content || FrontendRendererService.default_layout
  67. # Build page sections from template data
  68. page_sections = []
  69. template_content['order']&.each_with_index do |section_id, index|
  70. section_config = template_content['sections'][section_id]
  71. next unless section_config
  72. # Create a mock section object
  73. section = Object.new
  74. def section.section_id
  75. @section_id
  76. end
  77. def section.section_type
  78. @section_type
  79. end
  80. def section.settings
  81. @settings
  82. end
  83. def section.position
  84. @position
  85. end
  86. section.instance_variable_set(:@section_id, section_id)
  87. section.instance_variable_set(:@section_type, section_config['type'])
  88. section.instance_variable_set(:@settings, section_config['settings'] || {})
  89. section.instance_variable_set(:@position, index)
  90. page_sections << section
  91. end
  92. {
  93. template_name: template_name,
  94. template_content: template_content,
  95. layout_content: layout_content,
  96. theme_settings: {},
  97. page_sections: page_sections
  98. }
  99. end
  100. # Store the published_version and builder_theme_id for access in methods
  101. mock_theme.instance_variable_set(:@published_version, published_version)
  102. mock_theme.instance_variable_set(:@builder_theme_id, @builder_theme_id)
  103. # Add other methods that might be needed
  104. def mock_theme.theme_name
  105. @published_version.theme.name.underscore
  106. end
  107. def mock_theme.id
  108. @builder_theme_id || @published_version.id
  109. end
  110. mock_theme
  111. end
  112. def self.default_layout
  113. <<~HTML
  114. <!DOCTYPE html>
  115. <html lang="en">
  116. <head>
  117. <meta charset="UTF-8">
  118. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  119. <title>{{ page.title | default: site.title }}</title>
  120. </head>
  121. <body>
  122. {{ content_for_layout }}
  123. </body>
  124. </html>
  125. HTML
  126. end
  127. end

app/services/frontend_theme_renderer.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class FrontendThemeRenderer
  2. class << self
  3. def render_template(template_name, context = {})
  4. # Get the active theme
  5. active_theme = Theme.active.first
  6. return render_error('No active theme found') unless active_theme
  7. # Ensure PublishedThemeVersion exists
  8. published_version = ensure_published_version_exists(active_theme)
  9. return render_error('Failed to create published theme version') unless published_version
  10. # Use FrontendRendererService to render
  11. renderer = FrontendRendererService.new(published_version)
  12. begin
  13. renderer.render_template(template_name, context)
  14. rescue => e
  15. Rails.logger.error "Frontend rendering error: #{e.message}"
  16. render_error("Rendering error: #{e.message}")
  17. end
  18. end
  19. def load_assets
  20. # Get the active theme
  21. active_theme = Theme.active.first
  22. return { css: '', js: '' } unless active_theme
  23. # Ensure PublishedThemeVersion exists
  24. published_version = ensure_published_version_exists(active_theme)
  25. return { css: '', js: '' } unless published_version
  26. # Use FrontendRendererService to get assets
  27. renderer = FrontendRendererService.new(published_version)
  28. renderer.assets
  29. end
  30. def current_theme_name
  31. active_theme = Theme.active.first
  32. active_theme&.name&.underscore || 'default'
  33. end
  34. private
  35. def ensure_published_version_exists(theme)
  36. # Check if we already have a PublishedThemeVersion for this theme
  37. published_version = PublishedThemeVersion.where(theme: theme).latest.first
  38. if published_version
  39. Rails.logger.debug "Using existing PublishedThemeVersion #{published_version.id} for theme #{theme.name}"
  40. return published_version
  41. end
  42. Rails.logger.info "No PublishedThemeVersion found for #{theme.name}, creating initial version..."
  43. # Create initial PublishedThemeVersion
  44. published_version = PublishedThemeVersion.create!(
  45. theme: theme,
  46. version_number: 1,
  47. published_at: Time.current,
  48. published_by: User.first, # TODO: Use system user or current user if available
  49. tenant: theme.tenant
  50. )
  51. # Copy all files from ThemeVersion to PublishedThemeFile
  52. theme_version = ThemeVersion.for_theme(theme.name).live.first
  53. if theme_version && theme_version.theme_files.any?
  54. theme_version.theme_files.each do |theme_file|
  55. # Use the theme file's content directly
  56. content = theme_file.current_content
  57. next unless content
  58. # Use the file_path as is (it should already be relative)
  59. relative_path = theme_file.file_path
  60. PublishedThemeFile.create!(
  61. published_theme_version: published_version,
  62. file_path: relative_path,
  63. file_type: theme_file.file_type,
  64. content: content,
  65. checksum: Digest::MD5.hexdigest(content)
  66. )
  67. end
  68. Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
  69. else
  70. Rails.logger.warn "No theme files found for #{theme.name}"
  71. end
  72. published_version
  73. rescue => e
  74. Rails.logger.error "Failed to create PublishedThemeVersion for #{theme.name}: #{e.message}"
  75. nil
  76. end
  77. def render_error(message)
  78. <<~HTML
  79. <!DOCTYPE html>
  80. <html lang="en">
  81. <head>
  82. <meta charset="UTF-8">
  83. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  84. <title>Error - RailsPress</title>
  85. <style>
  86. body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem; background: #f5f5f5; }
  87. .error-container { max-width: 600px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
  88. .error-title { color: #dc2626; margin-bottom: 1rem; }
  89. .error-message { color: #374151; line-height: 1.6; }
  90. </style>
  91. </head>
  92. <body>
  93. <div class="error-container">
  94. <h1 class="error-title">Theme Error</h1>
  95. <p class="error-message">#{message}</p>
  96. </div>
  97. </body>
  98. </html>
  99. HTML
  100. end
  101. end
  102. end

app/services/gdpr_compliance_service.rb

0.0% lines covered

100.0% branches covered

351 relevant lines. 0 lines covered and 351 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class GdprComplianceService
  3. include ActiveSupport::Benchmarkable
  4. # Data subject rights under GDPR
  5. DATA_SUBJECT_RIGHTS = %w[
  6. right_to_be_informed
  7. right_of_access
  8. right_to_rectification
  9. right_to_erasure
  10. right_to_restrict_processing
  11. right_to_data_portability
  12. right_to_object
  13. rights_related_to_automated_decision_making
  14. ].freeze
  15. # Legal basis for processing under GDPR
  16. LEGAL_BASIS = %w[
  17. consent
  18. contract
  19. legal_obligation
  20. vital_interests
  21. public_task
  22. legitimate_interests
  23. ].freeze
  24. # Data categories we collect
  25. DATA_CATEGORIES = %w[
  26. identity_data
  27. contact_data
  28. technical_data
  29. usage_data
  30. marketing_data
  31. analytics_data
  32. geolocation_data
  33. ].freeze
  34. class << self
  35. # Check if GDPR applies to this request
  36. def gdpr_applies?(request)
  37. # GDPR applies to EU residents and EU data subjects
  38. eu_countries = %w[AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE]
  39. # Check if user is in EU based on IP geolocation
  40. country_code = get_country_from_request(request)
  41. eu_countries.include?(country_code)
  42. rescue
  43. # If we can't determine location, assume GDPR applies for safety
  44. true
  45. end
  46. # Get country code from request
  47. def get_country_from_request(request)
  48. # Try to get country from analytics data first
  49. session_id = request.session[:analytics_session_id]
  50. if session_id
  51. recent_pageview = Pageview.where(session_id: session_id)
  52. .where('visited_at >= ?', 1.hour.ago)
  53. .where.not(country_code: nil)
  54. .first
  55. return recent_pageview.country_code if recent_pageview
  56. end
  57. # Fallback to IP geolocation
  58. GeolocationService.lookup_ip(request.ip)&.dig(:country_code)
  59. rescue
  60. nil
  61. end
  62. # Check if user has given valid consent
  63. def has_valid_consent?(session_id, consent_type = 'analytics')
  64. return true unless SiteSetting.get('analytics_require_consent', true)
  65. # Check if consent is stored in session
  66. consent_key = "analytics_consent_#{consent_type}"
  67. Rails.cache.read("consent:#{session_id}:#{consent_key}") == true
  68. rescue
  69. false
  70. end
  71. # Store user consent
  72. def store_consent(session_id, consent_data)
  73. consent_data.each do |consent_type, granted|
  74. consent_key = "analytics_consent_#{consent_type}"
  75. Rails.cache.write("consent:#{session_id}:#{consent_key}", granted, expires_in: 1.year)
  76. end
  77. # Log consent for audit trail
  78. log_consent_event(session_id, consent_data)
  79. rescue => e
  80. Rails.logger.error "Failed to store consent: #{e.message}"
  81. end
  82. # Log consent event for audit trail
  83. def log_consent_event(session_id, consent_data)
  84. AnalyticsEvent.create!(
  85. event_name: 'gdpr_consent_updated',
  86. properties: {
  87. consent_data: consent_data,
  88. legal_basis: 'consent',
  89. data_categories: DATA_CATEGORIES,
  90. gdpr_compliant: true
  91. },
  92. session_id: session_id,
  93. tenant: ActsAsTenant.current_tenant || Tenant.first
  94. )
  95. rescue => e
  96. Rails.logger.error "Failed to log consent event: #{e.message}"
  97. end
  98. # Handle data subject access request
  99. def handle_data_access_request(session_id, request_data = {})
  100. # Collect all data related to this session/user
  101. data = {
  102. pageviews: collect_pageview_data(session_id),
  103. events: collect_event_data(session_id),
  104. consent_history: collect_consent_history(session_id),
  105. metadata: {
  106. request_date: Time.current,
  107. data_categories: DATA_CATEGORIES,
  108. retention_period: SiteSetting.get('analytics_data_retention_days', 365),
  109. legal_basis: 'consent'
  110. }
  111. }
  112. # Log the access request
  113. log_data_subject_request(session_id, 'access', request_data)
  114. data
  115. rescue => e
  116. Rails.logger.error "Failed to handle data access request: #{e.message}"
  117. { error: e.message }
  118. end
  119. # Handle data deletion request
  120. def handle_data_deletion_request(session_id, request_data = {})
  121. deleted_count = 0
  122. # Delete pageviews
  123. pageview_count = Pageview.where(session_id: session_id).count
  124. Pageview.where(session_id: session_id).delete_all
  125. deleted_count += pageview_count
  126. # Delete analytics events
  127. event_count = AnalyticsEvent.where(session_id: session_id).count
  128. AnalyticsEvent.where(session_id: session_id).delete_all
  129. deleted_count += event_count
  130. # Clear consent data
  131. clear_consent_data(session_id)
  132. # Log the deletion request
  133. log_data_subject_request(session_id, 'deletion', request_data.merge(deleted_records: deleted_count))
  134. { deleted_records: deleted_count, success: true }
  135. rescue => e
  136. Rails.logger.error "Failed to handle data deletion request: #{e.message}"
  137. { error: e.message }
  138. end
  139. # Handle data portability request
  140. def handle_data_portability_request(session_id, request_data = {})
  141. # Collect data in portable format
  142. data = handle_data_access_request(session_id, request_data)
  143. # Convert to JSON format for portability
  144. portable_data = {
  145. export_date: Time.current.iso8601,
  146. data_subject_id: session_id,
  147. data_categories: DATA_CATEGORIES,
  148. legal_basis: 'consent',
  149. data: data
  150. }
  151. # Log the portability request
  152. log_data_subject_request(session_id, 'portability', request_data)
  153. portable_data
  154. rescue => e
  155. Rails.logger.error "Failed to handle data portability request: #{e.message}"
  156. { error: e.message }
  157. end
  158. # Collect pageview data for data subject
  159. def collect_pageview_data(session_id)
  160. Pageview.where(session_id: session_id).map do |pageview|
  161. {
  162. id: pageview.id,
  163. path: pageview.path,
  164. title: pageview.title,
  165. visited_at: pageview.visited_at.iso8601,
  166. referrer: pageview.referrer,
  167. user_agent: pageview.user_agent,
  168. country: pageview.country_name,
  169. city: pageview.city,
  170. device: pageview.device,
  171. browser: pageview.browser,
  172. reading_time: pageview.reading_time,
  173. engagement_score: pageview.engagement_score,
  174. is_reader: pageview.is_reader
  175. }
  176. end
  177. end
  178. # Collect event data for data subject
  179. def collect_event_data(session_id)
  180. AnalyticsEvent.where(session_id: session_id).map do |event|
  181. {
  182. id: event.id,
  183. event_name: event.event_name,
  184. properties: event.properties,
  185. created_at: event.created_at.iso8601
  186. }
  187. end
  188. end
  189. # Collect consent history for data subject
  190. def collect_consent_history(session_id)
  191. # Get consent events from analytics
  192. consent_events = AnalyticsEvent.where(session_id: session_id)
  193. .where(event_name: 'gdpr_arnalytics_consent_updated')
  194. .order(:created_at)
  195. consent_events.map do |event|
  196. {
  197. event_id: event.id,
  198. consent_data: event.properties['consent_data'],
  199. timestamp: event.created_at.iso8601,
  200. legal_basis: event.properties['legal_basis']
  201. }
  202. end
  203. end
  204. # Clear consent data for data subject
  205. def clear_consent_data(session_id)
  206. # Clear all consent cache entries
  207. consent_types = %w[analytics marketing essential]
  208. consent_types.each do |consent_type|
  209. consent_key = "analytics_consent_#{consent_type}"
  210. Rails.cache.delete("consent:#{session_id}:#{consent_key}")
  211. end
  212. end
  213. # Log data subject request for audit trail
  214. def log_data_subject_request(session_id, request_type, request_data)
  215. AnalyticsEvent.create!(
  216. event_name: "gdpr_data_subject_request_#{request_type}",
  217. properties: {
  218. request_type: request_type,
  219. request_data: request_data,
  220. legal_basis: 'legal_obligation',
  221. gdpr_compliant: true,
  222. data_categories: DATA_CATEGORIES
  223. },
  224. session_id: session_id,
  225. tenant: ActsAsTenant.current_tenant
  226. )
  227. rescue => e
  228. Rails.logger.error "Failed to log data subject request: #{e.message}"
  229. end
  230. # Check if data processing is lawful
  231. def is_processing_lawful?(purpose, legal_basis, consent_given = false)
  232. case legal_basis
  233. when 'consent'
  234. consent_given
  235. when 'legitimate_interests'
  236. legitimate_interests_assessment(purpose)
  237. when 'contract'
  238. contract_processing_assessment(purpose)
  239. when 'legal_obligation'
  240. legal_obligation_assessment(purpose)
  241. else
  242. false
  243. end
  244. end
  245. # Assess legitimate interests
  246. def legitimate_interests_assessment(purpose)
  247. legitimate_purposes = %w[
  248. analytics
  249. security
  250. fraud_prevention
  251. service_improvement
  252. performance_monitoring
  253. ]
  254. legitimate_purposes.include?(purpose)
  255. end
  256. # Assess contract processing
  257. def contract_processing_assessment(purpose)
  258. contract_purposes = %w[
  259. user_authentication
  260. service_delivery
  261. payment_processing
  262. account_management
  263. ]
  264. contract_purposes.include?(purpose)
  265. end
  266. # Assess legal obligation
  267. def legal_obligation_assessment(purpose)
  268. legal_purposes = %w[
  269. tax_compliance
  270. audit_requirements
  271. regulatory_reporting
  272. law_enforcement
  273. ]
  274. legal_purposes.include?(purpose)
  275. end
  276. # Get privacy policy information
  277. def get_privacy_policy_info
  278. {
  279. data_controller: SiteSetting.get('data_controller_name', 'RailsPress'),
  280. data_controller_email: SiteSetting.get('data_controller_email', 'privacy@railspress.com'),
  281. dpo_email: SiteSetting.get('dpo_email', 'dpo@railspress.com'),
  282. data_categories: DATA_CATEGORIES,
  283. legal_basis: 'consent',
  284. retention_period: SiteSetting.get('analytics_data_retention_days', 365),
  285. data_subject_rights: DATA_SUBJECT_RIGHTS,
  286. third_party_sharing: get_third_party_sharing_info,
  287. data_transfers: get_data_transfer_info
  288. }
  289. rescue => e
  290. Rails.logger.error "Failed to get privacy policy info: #{e.message}"
  291. {
  292. data_controller: 'RailsPress',
  293. data_controller_email: 'privacy@railspress.com',
  294. dpo_email: 'dpo@railspress.com',
  295. data_categories: DATA_CATEGORIES,
  296. legal_basis: 'consent',
  297. retention_period: 365,
  298. data_subject_rights: DATA_SUBJECT_RIGHTS,
  299. third_party_sharing: {},
  300. data_transfers: {}
  301. }
  302. end
  303. # Get third party sharing information
  304. def get_third_party_sharing_info
  305. {
  306. google_analytics: {
  307. purpose: 'analytics',
  308. data_categories: %w[usage_data technical_data],
  309. legal_basis: 'consent',
  310. retention_period: 26 # months
  311. },
  312. maxmind: {
  313. purpose: 'geolocation',
  314. data_categories: %w[technical_data geolocation_data],
  315. legal_basis: 'legitimate_interests',
  316. retention_period: 365 # days
  317. }
  318. }
  319. end
  320. # Get data transfer information
  321. def get_data_transfer_info
  322. {
  323. adequacy_decision: false,
  324. safeguards: %w[standard_contractual_clauses],
  325. transfers_to: %w[United_States],
  326. transfer_purpose: 'analytics_and_geolocation'
  327. }
  328. end
  329. # Perform data protection impact assessment
  330. def perform_dpia(processing_activity)
  331. {
  332. processing_activity: processing_activity,
  333. risk_level: assess_risk_level(processing_activity),
  334. mitigation_measures: get_mitigation_measures(processing_activity),
  335. assessment_date: Time.current,
  336. assessor: 'RailsPress DPO'
  337. }
  338. end
  339. # Assess risk level
  340. def assess_risk_level(processing_activity)
  341. high_risk_activities = %w[
  342. large_scale_processing
  343. systematic_monitoring
  344. special_category_data
  345. automated_decision_making
  346. ]
  347. if high_risk_activities.any? { |activity| processing_activity.include?(activity) }
  348. 'high'
  349. else
  350. 'medium'
  351. end
  352. end
  353. # Get mitigation measures
  354. def get_mitigation_measures(processing_activity)
  355. measures = [
  356. 'data_minimization',
  357. 'purpose_limitation',
  358. 'storage_limitation',
  359. 'technical_and_organizational_measures',
  360. 'privacy_by_design',
  361. 'data_protection_by_default'
  362. ]
  363. if processing_activity.include?('large_scale_processing')
  364. measures += ['data_protection_impact_assessment', 'prior_consultation']
  365. end
  366. measures
  367. end
  368. # Check if processing is necessary and proportionate
  369. def is_processing_necessary_and_proportionate?(purpose, data_categories, legal_basis)
  370. # Check necessity
  371. necessary = is_processing_necessary?(purpose, data_categories)
  372. # Check proportionality
  373. proportionate = is_processing_proportionate?(purpose, data_categories, legal_basis)
  374. necessary && proportionate
  375. end
  376. # Check if processing is necessary
  377. def is_processing_necessary?(purpose, data_categories)
  378. case purpose
  379. when 'analytics'
  380. data_categories.include?('usage_data') && data_categories.include?('technical_data')
  381. when 'geolocation'
  382. data_categories.include?('geolocation_data')
  383. when 'security'
  384. data_categories.include?('technical_data')
  385. else
  386. false
  387. end
  388. end
  389. # Check if processing is proportionate
  390. def is_processing_proportionate?(purpose, data_categories, legal_basis)
  391. # Check if we're collecting only what's needed
  392. case purpose
  393. when 'analytics'
  394. data_categories.size <= 3 && legal_basis == 'consent'
  395. when 'geolocation'
  396. data_categories.size <= 2 && legal_basis == 'legitimate_interests'
  397. else
  398. data_categories.size <= 1
  399. end
  400. end
  401. end
  402. end

app/services/gdpr_service.rb

0.0% lines covered

100.0% branches covered

327 relevant lines. 0 lines covered and 327 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class GdprService
  2. include Rails.application.routes.url_helpers
  3. class << self
  4. # Create a personal data export request
  5. def create_export_request(user, requested_by, options = {})
  6. # Check if there's already a pending request
  7. existing_request = PersonalDataExportRequest.where(user: user, status: ['pending', 'processing']).first
  8. if existing_request
  9. raise StandardError, 'An export request is already pending or processing for this user'
  10. end
  11. # Create the request
  12. export_request = PersonalDataExportRequest.create!(
  13. user: user,
  14. email: user.email,
  15. requested_by: requested_by.id,
  16. status: 'pending',
  17. tenant: user.tenant
  18. )
  19. # Queue the export job
  20. PersonalDataExportWorker.perform_async(export_request.id)
  21. # Log the action
  22. log_gdpr_action('export_requested', user, requested_by, {
  23. request_id: export_request.id,
  24. email: user.email
  25. })
  26. export_request
  27. end
  28. # Create a personal data erasure request
  29. def create_erasure_request(user, requested_by, reason = nil)
  30. # Check if there's already a pending request
  31. existing_request = PersonalDataErasureRequest.where(user: user, status: ['pending_confirmation', 'processing']).first
  32. if existing_request
  33. raise StandardError, 'An erasure request is already pending or processing for this user'
  34. end
  35. # Gather metadata about what will be erased
  36. metadata = gather_erasure_metadata(user)
  37. # Create the request
  38. erasure_request = PersonalDataErasureRequest.create!(
  39. user: user,
  40. email: user.email,
  41. requested_by: requested_by.id,
  42. status: 'pending_confirmation',
  43. reason: reason,
  44. metadata: metadata,
  45. tenant: user.tenant
  46. )
  47. # Log the action
  48. log_gdpr_action('erasure_requested', user, requested_by, {
  49. request_id: erasure_request.id,
  50. reason: reason,
  51. metadata: metadata
  52. })
  53. erasure_request
  54. end
  55. # Confirm an erasure request
  56. def confirm_erasure_request(erasure_request, confirmed_by)
  57. erasure_request.update!(
  58. status: 'processing',
  59. confirmed_at: Time.current,
  60. confirmed_by: confirmed_by.id
  61. )
  62. # Queue the erasure job
  63. PersonalDataErasureWorker.perform_async(erasure_request.id)
  64. # Log the action
  65. log_gdpr_action('erasure_confirmed', erasure_request.user, confirmed_by, {
  66. request_id: erasure_request.id,
  67. reason: erasure_request.reason
  68. })
  69. erasure_request
  70. end
  71. # Generate comprehensive data portability information
  72. def generate_portability_data(user)
  73. {
  74. user_profile: {
  75. id: user.id,
  76. email: user.email,
  77. name: user.name,
  78. role: user.role,
  79. bio: user.bio,
  80. website: user.website,
  81. created_at: user.created_at,
  82. updated_at: user.updated_at,
  83. last_sign_in_at: user.last_sign_in_at,
  84. sign_in_count: user.sign_in_count
  85. },
  86. posts: user.posts.map do |post|
  87. {
  88. id: post.id,
  89. title: post.title,
  90. slug: post.slug,
  91. content: post.content.to_s,
  92. excerpt: post.excerpt,
  93. status: post.status,
  94. published_at: post.published_at,
  95. created_at: post.created_at,
  96. updated_at: post.updated_at,
  97. categories: post.categories.map(&:name),
  98. tags: post.tags.map(&:name)
  99. }
  100. end,
  101. pages: user.pages.map do |page|
  102. {
  103. id: page.id,
  104. title: page.title,
  105. slug: page.slug,
  106. content: page.content.to_s,
  107. status: page.status,
  108. published_at: page.published_at,
  109. created_at: page.created_at,
  110. updated_at: page.updated_at
  111. }
  112. end,
  113. comments: Comment.where(author_email: user.email).map do |comment|
  114. {
  115. id: comment.id,
  116. content: comment.content,
  117. author_name: comment.author_name,
  118. author_email: comment.author_email,
  119. status: comment.status,
  120. post_title: comment.commentable&.title,
  121. created_at: comment.created_at,
  122. updated_at: comment.updated_at
  123. }
  124. end,
  125. media: user.media.map do |medium|
  126. {
  127. id: medium.id,
  128. filename: medium.filename,
  129. content_type: medium.content_type,
  130. file_size: medium.file_size,
  131. alt_text: medium.alt_text,
  132. caption: medium.caption,
  133. created_at: medium.created_at
  134. }
  135. end,
  136. subscribers: Subscriber.where(email: user.email).map do |subscriber|
  137. {
  138. id: subscriber.id,
  139. email: subscriber.email,
  140. status: subscriber.status,
  141. subscribed_at: subscriber.created_at,
  142. confirmed_at: subscriber.confirmed_at,
  143. lists: subscriber.lists
  144. }
  145. end,
  146. api_tokens: user.api_tokens.map do |token|
  147. {
  148. id: token.id,
  149. name: token.name,
  150. last_used_at: token.last_used_at,
  151. created_at: token.created_at
  152. }
  153. end,
  154. meta_fields: user.meta_fields.map do |field|
  155. {
  156. key: field.key,
  157. value: field.value,
  158. created_at: field.created_at,
  159. updated_at: field.updated_at
  160. }
  161. end,
  162. analytics_data: {
  163. pageviews: Pageview.where(user_id: user.id).group(:path).count,
  164. total_pageviews: Pageview.where(user_id: user.id).count,
  165. last_pageview: Pageview.where(user_id: user.id).order(:created_at).last&.created_at
  166. },
  167. consent_records: get_user_consent_records(user),
  168. gdpr_requests: {
  169. export_requests: PersonalDataExportRequest.where(user: user).map do |req|
  170. {
  171. id: req.id,
  172. status: req.status,
  173. requested_at: req.created_at,
  174. completed_at: req.completed_at
  175. }
  176. end,
  177. erasure_requests: PersonalDataErasureRequest.where(user: user).map do |req|
  178. {
  179. id: req.id,
  180. status: req.status,
  181. reason: req.reason,
  182. requested_at: req.created_at,
  183. confirmed_at: req.confirmed_at,
  184. completed_at: req.completed_at
  185. }
  186. end
  187. },
  188. metadata: {
  189. total_posts: user.posts.count,
  190. total_pages: user.pages.count,
  191. total_comments: Comment.where(author_email: user.email).count,
  192. total_media: user.media.count,
  193. total_subscribers: Subscriber.where(email: user.email).count,
  194. export_date: Time.current
  195. }
  196. }
  197. end
  198. # Get GDPR compliance status for a user
  199. def get_user_gdpr_status(user)
  200. {
  201. user_id: user.id,
  202. email: user.email,
  203. compliance_status: {
  204. data_processing_consent: get_consent_status(user, 'data_processing'),
  205. marketing_consent: get_consent_status(user, 'marketing'),
  206. analytics_consent: get_consent_status(user, 'analytics'),
  207. cookie_consent: get_consent_status(user, 'cookies')
  208. },
  209. data_retention: {
  210. account_created: user.created_at,
  211. last_activity: user.last_sign_in_at || user.updated_at,
  212. data_age_days: (Time.current - user.created_at).to_i / 1.day
  213. },
  214. pending_requests: {
  215. export_requests: PersonalDataExportRequest.where(user: user, status: ['pending', 'processing']).count,
  216. erasure_requests: PersonalDataErasureRequest.where(user: user, status: ['pending_confirmation', 'processing']).count
  217. },
  218. data_categories: {
  219. profile_data: true,
  220. content_data: user.posts.exists? || user.pages.exists?,
  221. communication_data: Comment.where(author_email: user.email).exists?,
  222. analytics_data: Pageview.where(user_id: user.id).exists?,
  223. media_data: user.media.exists?,
  224. subscription_data: Subscriber.where(email: user.email).exists?
  225. },
  226. legal_basis: {
  227. consent: get_consent_status(user, 'data_processing') == 'granted',
  228. withhold_consent: get_consent_status(user, 'data_processing') == 'withdrawn',
  229. legitimate_interest: true # For analytics and security
  230. }
  231. }
  232. end
  233. # Record user consent
  234. def record_user_consent(user, consent_type, consent_data)
  235. consent_record = UserConsent.find_or_initialize_by(
  236. user: user,
  237. consent_type: consent_type
  238. )
  239. consent_record.assign_attributes(
  240. granted: consent_data[:granted] || false,
  241. consent_text: consent_data[:consent_text],
  242. ip_address: consent_data[:ip_address],
  243. user_agent: consent_data[:user_agent],
  244. granted_at: consent_data[:granted] ? Time.current : nil,
  245. withdrawn_at: consent_data[:granted] ? nil : Time.current
  246. )
  247. consent_record.save!
  248. # Log the action
  249. log_gdpr_action('consent_recorded', user, nil, {
  250. consent_type: consent_type,
  251. granted: consent_data[:granted],
  252. consent_text: consent_data[:consent_text]
  253. })
  254. consent_record
  255. end
  256. # Withdraw user consent
  257. def withdraw_user_consent(user, consent_type)
  258. consent_record = UserConsent.find_by(user: user, consent_type: consent_type)
  259. if consent_record
  260. consent_record.update!(
  261. granted: false,
  262. withdrawn_at: Time.current
  263. )
  264. # Log the action
  265. log_gdpr_action('consent_withdrawn', user, nil, {
  266. consent_type: consent_type
  267. })
  268. consent_record
  269. else
  270. raise StandardError, 'No consent record found for this user and consent type'
  271. end
  272. end
  273. # Get audit log for GDPR compliance
  274. def get_audit_log(page = 1, per_page = 50)
  275. offset = (page - 1) * per_page
  276. # This would typically come from a dedicated audit log table
  277. # For now, we'll simulate with existing data
  278. audit_entries = []
  279. # Export requests
  280. PersonalDataExportRequest.includes(:user).recent.limit(per_page / 2).each do |req|
  281. audit_entries << {
  282. id: req.id,
  283. action: 'data_export_requested',
  284. user_email: req.email,
  285. timestamp: req.created_at,
  286. details: {
  287. status: req.status,
  288. completed_at: req.completed_at
  289. }
  290. }
  291. end
  292. # Erasure requests
  293. PersonalDataErasureRequest.includes(:user).recent.limit(per_page / 2).each do |req|
  294. audit_entries << {
  295. id: req.id,
  296. action: 'data_erasure_requested',
  297. user_email: req.email,
  298. timestamp: req.created_at,
  299. details: {
  300. status: req.status,
  301. reason: req.reason,
  302. confirmed_at: req.confirmed_at,
  303. completed_at: req.completed_at
  304. }
  305. }
  306. end
  307. # Sort by timestamp and paginate
  308. audit_entries.sort_by { |entry| entry[:timestamp] }.reverse
  309. .slice(offset, per_page)
  310. end
  311. private
  312. # Gather metadata about what will be erased
  313. def gather_erasure_metadata(user)
  314. {
  315. user_posts_count: user.posts.count,
  316. user_pages_count: user.pages.count,
  317. user_comments_count: Comment.where(author_email: user.email).count,
  318. user_media_count: user.media.count,
  319. user_subscribers_count: Subscriber.where(email: user.email).count,
  320. user_pageviews_count: Pageview.where(user_id: user.id).count,
  321. user_api_tokens_count: user.api_tokens.count,
  322. user_meta_fields_count: user.meta_fields.count,
  323. account_age_days: (Time.current - user.created_at).to_i / 1.day,
  324. last_activity: user.last_sign_in_at || user.updated_at
  325. }
  326. end
  327. # Get consent status for a user
  328. def get_consent_status(user, consent_type)
  329. consent_record = UserConsent.find_by(user: user, consent_type: consent_type)
  330. if consent_record
  331. consent_record.granted ? 'granted' : 'withdrawn'
  332. else
  333. 'not_recorded'
  334. end
  335. end
  336. # Get user consent records
  337. def get_user_consent_records(user)
  338. UserConsent.where(user: user).map do |consent|
  339. {
  340. consent_type: consent.consent_type,
  341. granted: consent.granted,
  342. consent_text: consent.consent_text,
  343. granted_at: consent.granted_at,
  344. withdrawn_at: consent.withdrawn_at,
  345. ip_address: consent.ip_address,
  346. created_at: consent.created_at,
  347. updated_at: consent.updated_at
  348. }
  349. end
  350. end
  351. # Log GDPR actions for audit trail
  352. def log_gdpr_action(action, user, performed_by, details = {})
  353. # In a real implementation, this would write to a dedicated audit log table
  354. Rails.logger.info("GDPR Action: #{action} - User: #{user.email} - Performed by: #{performed_by&.email} - Details: #{details.to_json}")
  355. # You could also store this in a dedicated audit log model:
  356. # GdprAuditLog.create!(
  357. # action: action,
  358. # user: user,
  359. # performed_by: performed_by,
  360. # details: details,
  361. # ip_address: RequestStore[:current_request]&.remote_ip,
  362. # user_agent: RequestStore[:current_request]&.user_agent
  363. # )
  364. end
  365. end
  366. end

app/services/geolocation_service.rb

0.0% lines covered

100.0% branches covered

293 relevant lines. 0 lines covered and 293 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class GeolocationService
  2. include Singleton
  3. PROVIDERS = {
  4. 'maxmind' => 'MaxMind GeoLite2 Database',
  5. 'ipapi' => 'IP-API.com (Free)',
  6. 'ipinfo' => 'IPInfo.io (Free tier)',
  7. 'ipgeolocation' => 'IP Geolocation API',
  8. 'abstract' => 'Abstract API'
  9. }.freeze
  10. def initialize
  11. @provider = SiteSetting.get('geolocation_provider', 'maxmind')
  12. @maxmind_db_path = Rails.root.join('db', 'maxmind', 'GeoLite2-Country.mmdb')
  13. @maxmind_city_db_path = Rails.root.join('db', 'maxmind', 'GeoLite2-City.mmdb')
  14. end
  15. def lookup_ip(ip_address)
  16. return nil if ip_address.blank? || private_ip?(ip_address)
  17. # Check if geolocation is enabled (disabled by default for GDPR compliance)
  18. return nil unless SiteSetting.get('geolocation_enabled', false)
  19. # Check if user has consented (if consent is required)
  20. if SiteSetting.get('geolocation_require_consent', true)
  21. # This would need to be implemented based on your consent system
  22. # For now, we'll assume consent is given if geolocation is enabled
  23. end
  24. # Anonymize IP if required
  25. processed_ip = ip_address
  26. if SiteSetting.get('geolocation_anonymize_ip', true) && !SiteSetting.get('geolocation_full_power_mode', false)
  27. processed_ip = anonymize_ip(ip_address)
  28. end
  29. case @provider
  30. when 'maxmind'
  31. maxmind_lookup(processed_ip)
  32. when 'ipapi'
  33. ipapi_lookup(processed_ip)
  34. when 'ipinfo'
  35. ipinfo_lookup(processed_ip)
  36. when 'ipgeolocation'
  37. ipgeolocation_lookup(processed_ip)
  38. when 'abstract'
  39. abstract_lookup(processed_ip)
  40. else
  41. maxmind_lookup(processed_ip) # fallback to MaxMind
  42. end
  43. rescue => e
  44. Rails.logger.error "Geolocation lookup failed for #{ip_address}: #{e.message}"
  45. nil
  46. end
  47. def maxmind_lookup(ip_address)
  48. return nil unless maxmind_available?
  49. begin
  50. # Try City database first for more detailed info
  51. if File.exist?(@maxmind_city_db_path)
  52. db = MaxMindDB.new(@maxmind_city_db_path.to_s)
  53. result = db.lookup(ip_address)
  54. if result.found?
  55. city_record = result.record
  56. return {
  57. country_code: city_record.country.iso_code,
  58. country_name: city_record.country.names['en'],
  59. city: city_record.city.names['en'],
  60. region: city_record.subdivisions&.first&.names&.dig('en'),
  61. latitude: city_record.location.latitude,
  62. longitude: city_record.location.longitude,
  63. timezone: city_record.location.time_zone,
  64. accuracy_radius: city_record.location.accuracy_radius,
  65. provider: 'maxmind_city'
  66. }
  67. end
  68. end
  69. # Fallback to Country database
  70. if File.exist?(@maxmind_db_path)
  71. db = MaxMindDB.new(@maxmind_db_path.to_s)
  72. result = db.lookup(ip_address)
  73. if result.found?
  74. country_record = result.record
  75. return {
  76. country_code: country_record.country.iso_code,
  77. country_name: country_record.country.names['en'],
  78. provider: 'maxmind_country'
  79. }
  80. end
  81. end
  82. rescue => e
  83. Rails.logger.error "MaxMind lookup failed: #{e.message}"
  84. end
  85. nil
  86. end
  87. def ipapi_lookup(ip_address)
  88. return nil unless SiteSetting.get('geolocation_ipapi_enabled', false)
  89. begin
  90. response = HTTP.timeout(5).get("http://ip-api.com/json/#{ip_address}")
  91. data = JSON.parse(response.body.to_s)
  92. if data['status'] == 'success'
  93. {
  94. country_code: data['countryCode'],
  95. country_name: data['country'],
  96. city: data['city'],
  97. region: data['regionName'],
  98. latitude: data['lat'],
  99. longitude: data['lon'],
  100. timezone: data['timezone'],
  101. isp: data['isp'],
  102. org: data['org'],
  103. provider: 'ipapi'
  104. }
  105. end
  106. rescue => e
  107. Rails.logger.error "IP-API lookup failed: #{e.message}"
  108. end
  109. nil
  110. end
  111. def ipinfo_lookup(ip_address)
  112. return nil unless SiteSetting.get('geolocation_ipinfo_enabled', false)
  113. api_key = SiteSetting.get('geolocation_ipinfo_api_key', '')
  114. return nil if api_key.blank?
  115. begin
  116. url = "https://ipinfo.io/#{ip_address}/json"
  117. url += "?token=#{api_key}" if api_key.present?
  118. response = HTTP.timeout(5).get(url)
  119. data = JSON.parse(response.body.to_s)
  120. unless data['error']
  121. lat_lng = data['loc']&.split(',')
  122. {
  123. country_code: data['country'],
  124. country_name: country_name_from_code(data['country']),
  125. city: data['city'],
  126. region: data['region'],
  127. latitude: lat_lng&.first&.to_f,
  128. longitude: lat_lng&.last&.to_f,
  129. timezone: data['timezone'],
  130. isp: data['org'],
  131. provider: 'ipinfo'
  132. }
  133. end
  134. rescue => e
  135. Rails.logger.error "IPInfo lookup failed: #{e.message}"
  136. end
  137. nil
  138. end
  139. def ipgeolocation_lookup(ip_address)
  140. return nil unless SiteSetting.get('geolocation_ipgeolocation_enabled', false)
  141. api_key = SiteSetting.get('geolocation_ipgeolocation_api_key', '')
  142. return nil if api_key.blank?
  143. begin
  144. response = HTTP.timeout(5).get("https://api.ipgeolocation.io/ipgeo", params: {
  145. apiKey: api_key,
  146. ip: ip_address
  147. })
  148. data = JSON.parse(response.body.to_s)
  149. {
  150. country_code: data['country_code2'],
  151. country_name: data['country_name'],
  152. city: data['city'],
  153. region: data['state_prov'],
  154. latitude: data['latitude']&.to_f,
  155. longitude: data['longitude']&.to_f,
  156. timezone: data['time_zone']&.dig('name'),
  157. isp: data['isp'],
  158. provider: 'ipgeolocation'
  159. }
  160. rescue => e
  161. Rails.logger.error "IP Geolocation lookup failed: #{e.message}"
  162. end
  163. nil
  164. end
  165. def abstract_lookup(ip_address)
  166. return nil unless SiteSetting.get('geolocation_abstract_enabled', false)
  167. api_key = SiteSetting.get('geolocation_abstract_api_key', '')
  168. return nil if api_key.blank?
  169. begin
  170. response = HTTP.timeout(5).get("https://ipgeolocation.abstractapi.com/v1/", params: {
  171. api_key: api_key,
  172. ip_address: ip_address
  173. })
  174. data = JSON.parse(response.body.to_s)
  175. {
  176. country_code: data['country_code'],
  177. country_name: data['country'],
  178. city: data['city'],
  179. region: data['region'],
  180. latitude: data['latitude']&.to_f,
  181. longitude: data['longitude']&.to_f,
  182. timezone: data['timezone']&.dig('name'),
  183. provider: 'abstract'
  184. }
  185. rescue => e
  186. Rails.logger.error "Abstract API lookup failed: #{e.message}"
  187. end
  188. nil
  189. end
  190. def maxmind_available?
  191. File.exist?(@maxmind_db_path) || File.exist?(@maxmind_city_db_path)
  192. end
  193. def maxmind_database_info
  194. info = {}
  195. if File.exist?(@maxmind_db_path)
  196. stat = File.stat(@maxmind_db_path)
  197. info[:country_db] = {
  198. path: @maxmind_db_path,
  199. size: stat.size,
  200. modified: stat.mtime,
  201. available: true
  202. }
  203. end
  204. if File.exist?(@maxmind_city_db_path)
  205. stat = File.stat(@maxmind_city_db_path)
  206. info[:city_db] = {
  207. path: @maxmind_city_db_path,
  208. size: stat.size,
  209. modified: stat.mtime,
  210. available: true
  211. }
  212. end
  213. info
  214. end
  215. def test_lookup(ip_address = '8.8.8.8')
  216. result = lookup_ip(ip_address)
  217. if result
  218. {
  219. success: true,
  220. data: result,
  221. provider: result[:provider],
  222. message: "Successfully resolved #{ip_address}"
  223. }
  224. else
  225. {
  226. success: false,
  227. message: "Failed to resolve #{ip_address} with provider #{@provider}"
  228. }
  229. end
  230. end
  231. private
  232. def private_ip?(ip_address)
  233. ip = IPAddr.new(ip_address)
  234. ip.private? || ip.loopback? || ip.link_local?
  235. rescue
  236. true # treat invalid IPs as private
  237. end
  238. def anonymize_ip(ip_address)
  239. # Anonymize IP by zeroing out the last octet for IPv4 or last 80 bits for IPv6
  240. begin
  241. ip = IPAddr.new(ip_address)
  242. if ip.ipv4?
  243. # Zero out the last octet for IPv4
  244. parts = ip_address.split('.')
  245. parts[3] = '0'
  246. parts.join('.')
  247. elsif ip.ipv6?
  248. # Zero out the last 80 bits for IPv6 (last 10 hex characters)
  249. ip_str = ip.to_s
  250. if ip_str.include?('::')
  251. # Handle compressed IPv6 addresses
  252. ip_str.gsub(/::[^:]*$/, '::')
  253. else
  254. # Handle full IPv6 addresses
  255. parts = ip_str.split(':')
  256. parts[-1] = '0000'
  257. parts.join(':')
  258. end
  259. else
  260. ip_address
  261. end
  262. rescue
  263. ip_address # return original if anonymization fails
  264. end
  265. end
  266. def filter_geolocation_data(data)
  267. # Filter data based on GDPR settings
  268. filtered_data = {}
  269. # Always include country if enabled
  270. if SiteSetting.get('geolocation_collect_country', true) && data[:country_code]
  271. filtered_data[:country_code] = data[:country_code]
  272. filtered_data[:country_name] = data[:country_name]
  273. end
  274. # Include region if enabled
  275. if SiteSetting.get('geolocation_collect_region', false) && data[:region]
  276. filtered_data[:region] = data[:region]
  277. end
  278. # Include city if enabled
  279. if SiteSetting.get('geolocation_collect_city', false) && data[:city]
  280. filtered_data[:city] = data[:city]
  281. end
  282. # Include coordinates if enabled
  283. if SiteSetting.get('geolocation_collect_coordinates', false) && data[:latitude] && data[:longitude]
  284. filtered_data[:latitude] = data[:latitude]
  285. filtered_data[:longitude] = data[:longitude]
  286. end
  287. # Include timezone if available
  288. filtered_data[:timezone] = data[:timezone] if data[:timezone]
  289. filtered_data
  290. end
  291. def country_name_from_code(code)
  292. # Simple country code to name mapping
  293. country_names = {
  294. 'US' => 'United States',
  295. 'GB' => 'United Kingdom',
  296. 'CA' => 'Canada',
  297. 'DE' => 'Germany',
  298. 'FR' => 'France',
  299. 'IT' => 'Italy',
  300. 'ES' => 'Spain',
  301. 'NL' => 'Netherlands',
  302. 'AU' => 'Australia',
  303. 'JP' => 'Japan',
  304. 'CN' => 'China',
  305. 'IN' => 'India',
  306. 'BR' => 'Brazil',
  307. 'RU' => 'Russia',
  308. 'MX' => 'Mexico'
  309. }
  310. country_names[code] || code
  311. end
  312. end

app/services/image_optimization_service.rb

0.0% lines covered

100.0% branches covered

435 relevant lines. 0 lines covered and 435 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ImageOptimizationService
  2. include ActiveSupport::Configurable
  3. # Configuration defaults
  4. config_accessor :quality, :max_width, :max_height, :strip_metadata, :enable_webp, :enable_avif, :compression_level
  5. # Default values
  6. self.quality = 85
  7. self.max_width = 2000
  8. self.max_height = 2000
  9. self.strip_metadata = true
  10. self.enable_webp = true
  11. self.enable_avif = true
  12. self.compression_level = 6
  13. # Supported image formats
  14. SUPPORTED_FORMATS = {
  15. # Traditional formats
  16. 'jpeg' => { mime: 'image/jpeg', extension: 'jpg', modern: false },
  17. 'png' => { mime: 'image/png', extension: 'png', modern: false },
  18. 'gif' => { mime: 'image/gif', extension: 'gif', modern: false },
  19. 'bmp' => { mime: 'image/bmp', extension: 'bmp', modern: false },
  20. 'tiff' => { mime: 'image/tiff', extension: 'tiff', modern: false },
  21. # Modern formats
  22. 'webp' => { mime: 'image/webp', extension: 'webp', modern: true, compression: 'excellent' },
  23. 'avif' => { mime: 'image/avif', extension: 'avif', modern: true, compression: 'excellent' },
  24. 'heic' => { mime: 'image/heic', extension: 'heic', modern: true, compression: 'excellent' },
  25. 'heif' => { mime: 'image/heif', extension: 'heif', modern: true, compression: 'excellent' },
  26. 'jxl' => { mime: 'image/jxl', extension: 'jxl', modern: true, compression: 'excellent' },
  27. 'jp2' => { mime: 'image/jp2', extension: 'jp2', modern: true, compression: 'good' },
  28. 'j2k' => { mime: 'image/j2k', extension: 'j2k', modern: true, compression: 'good' }
  29. }.freeze
  30. # Compression level configurations (inspired by Smush)
  31. COMPRESSION_LEVELS = {
  32. 'lossless' => {
  33. name: 'Lossless',
  34. description: 'Maximum quality, minimal compression',
  35. quality: 95,
  36. compression_level: 1,
  37. lossy: false,
  38. expected_savings: '5-15%',
  39. recommended_for: 'Professional photography, high-quality images'
  40. },
  41. 'lossy' => {
  42. name: 'Lossy',
  43. description: 'Balanced quality and compression',
  44. quality: 85,
  45. compression_level: 6,
  46. lossy: true,
  47. expected_savings: '25-40%',
  48. recommended_for: 'General web images, blog posts'
  49. },
  50. 'ultra' => {
  51. name: 'Ultra',
  52. description: 'Maximum compression, slight quality loss',
  53. quality: 75,
  54. compression_level: 9,
  55. lossy: true,
  56. expected_savings: '40-60%',
  57. recommended_for: 'High-traffic sites, mobile optimization'
  58. },
  59. 'custom' => {
  60. name: 'Custom',
  61. description: 'User-defined settings',
  62. quality: 85, # Default fallback
  63. compression_level: 6, # Default fallback
  64. lossy: true, # Default fallback
  65. expected_savings: 'Variable',
  66. recommended_for: 'Advanced users'
  67. }
  68. }.freeze
  69. def initialize(medium, optimization_type: 'upload', request_context: {})
  70. @medium = medium
  71. @upload = medium&.upload
  72. @storage_config = StorageConfigurationService.new
  73. @optimization_type = optimization_type
  74. @request_context = request_context
  75. @start_time = Time.current
  76. @log_entry = nil
  77. load_settings
  78. end
  79. # Main optimization method
  80. def optimize!
  81. return false unless should_optimize?
  82. Rails.logger.info "Starting image optimization for medium #{@medium.id}"
  83. # Create log entry
  84. create_log_entry
  85. begin
  86. # Get original file
  87. original_file = @upload.file.download
  88. original_size = original_file.size
  89. # Process the image
  90. processed_file = process_image(original_file)
  91. if processed_file && processed_file.size < original_size
  92. # Replace the original file with optimized version
  93. replace_file(processed_file)
  94. # Generate variants (WebP, AVIF, HEIC, JXL, etc.)
  95. variants_generated = []
  96. responsive_variants_generated = []
  97. if variants_enabled?
  98. variants_generated = generate_all_variants(original_file)
  99. responsive_variants_generated = generate_responsive_variants!(original_file)
  100. end
  101. # Update log entry with success
  102. update_log_entry(
  103. status: 'success',
  104. original_size: original_size,
  105. optimized_size: processed_file.size,
  106. variants_generated: variants_generated,
  107. responsive_variants_generated: responsive_variants_generated
  108. )
  109. Rails.logger.info "Image optimization completed for medium #{@medium.id}. Size reduced from #{original_size} to #{processed_file.size} bytes"
  110. true
  111. else
  112. # Update log entry with skipped status
  113. update_log_entry(
  114. status: 'skipped',
  115. original_size: original_size,
  116. optimized_size: original_size,
  117. error_message: 'No size reduction achieved'
  118. )
  119. Rails.logger.info "Image optimization skipped for medium #{@medium.id} - no size reduction achieved"
  120. false
  121. end
  122. rescue => e
  123. # Update log entry with error
  124. update_log_entry(
  125. status: 'failed',
  126. original_size: original_file&.size || 0,
  127. optimized_size: original_file&.size || 0,
  128. error_message: e.message
  129. )
  130. Rails.logger.error "Image optimization failed for medium #{@medium.id}: #{e.message}"
  131. Rails.logger.error e.backtrace.join("\n")
  132. false
  133. end
  134. end
  135. # Generate all modern format variants
  136. def generate_all_variants(original_file)
  137. variants_generated = []
  138. # Generate WebP variant
  139. if enable_webp
  140. webp_file = generate_format_variant(original_file, 'webp')
  141. if webp_file
  142. store_variant(webp_file, 'webp')
  143. variants_generated << 'webp'
  144. end
  145. end
  146. # Generate AVIF variant
  147. if enable_avif
  148. avif_file = generate_format_variant(original_file, 'avif')
  149. if avif_file
  150. store_variant(avif_file, 'avif')
  151. variants_generated << 'avif'
  152. end
  153. end
  154. # Generate HEIC variant (if enabled)
  155. if SiteSetting.get('enable_heic_variants', false)
  156. heic_file = generate_format_variant(original_file, 'heic')
  157. if heic_file
  158. store_variant(heic_file, 'heic')
  159. variants_generated << 'heic'
  160. end
  161. end
  162. # Generate JXL variant (if enabled)
  163. if SiteSetting.get('enable_jxl_variants', false)
  164. jxl_file = generate_format_variant(original_file, 'jxl')
  165. if jxl_file
  166. store_variant(jxl_file, 'jxl')
  167. variants_generated << 'jxl'
  168. end
  169. end
  170. variants_generated
  171. end
  172. # Generate optimized variants (legacy method for compatibility)
  173. def generate_variants!
  174. return false unless variants_enabled?
  175. Rails.logger.info "Generating image variants for medium #{@medium.id}"
  176. begin
  177. original_file = @upload.file.download
  178. variants_generated = generate_all_variants(original_file)
  179. # Generate responsive breakpoint variants
  180. generate_responsive_variants!(original_file)
  181. Rails.logger.info "Image variants generated for medium #{@medium.id}: #{variants_generated.join(', ')}"
  182. true
  183. rescue => e
  184. Rails.logger.error "Variant generation failed for medium #{@medium.id}: #{e.message}"
  185. false
  186. end
  187. end
  188. # Generate responsive variants for different breakpoints
  189. def generate_responsive_variants!(original_file)
  190. return false unless SiteSetting.get('enable_responsive_variants', true)
  191. breakpoints = SiteSetting.get('responsive_breakpoints', '320,640,768,1024,1200,1920').split(',').map(&:to_i)
  192. responsive_variants_generated = []
  193. breakpoints.each do |width|
  194. # Generate WebP responsive variants
  195. if enable_webp
  196. webp_responsive = generate_responsive_variant(original_file, width, 'webp')
  197. if webp_responsive
  198. store_responsive_variant(webp_responsive, 'webp', width)
  199. responsive_variants_generated << "webp_#{width}w"
  200. end
  201. end
  202. # Generate AVIF responsive variants
  203. if enable_avif
  204. avif_responsive = generate_responsive_variant(original_file, width, 'avif')
  205. if avif_responsive
  206. store_responsive_variant(avif_responsive, 'avif', width)
  207. responsive_variants_generated << "avif_#{width}w"
  208. end
  209. end
  210. # Generate original format responsive variants
  211. original_responsive = generate_responsive_variant(original_file, width, 'original')
  212. if original_responsive
  213. store_responsive_variant(original_responsive, 'original', width)
  214. responsive_variants_generated << "original_#{width}w"
  215. end
  216. end
  217. responsive_variants_generated
  218. end
  219. # Get compression level information
  220. def compression_level_info
  221. @compression_config || COMPRESSION_LEVELS['lossy']
  222. end
  223. def compression_level_name
  224. @compression_level_name || 'lossy'
  225. end
  226. def expected_savings
  227. compression_level_info[:expected_savings]
  228. end
  229. def recommended_for
  230. compression_level_info[:recommended_for]
  231. end
  232. # Class method to get all available compression levels
  233. def self.available_compression_levels
  234. COMPRESSION_LEVELS
  235. end
  236. # Class method to get all supported formats
  237. def self.supported_formats
  238. SUPPORTED_FORMATS
  239. end
  240. # Class method to get modern formats only
  241. def self.modern_formats
  242. SUPPORTED_FORMATS.select { |_, config| config[:modern] }
  243. end
  244. # Class method to get traditional formats only
  245. def self.traditional_formats
  246. SUPPORTED_FORMATS.select { |_, config| !config[:modern] }
  247. end
  248. # Class method to check if format is supported
  249. def self.supports_format?(format)
  250. SUPPORTED_FORMATS.key?(format.to_s.downcase)
  251. end
  252. # Class method to check if format is modern
  253. def self.modern_format?(format)
  254. SUPPORTED_FORMATS[format.to_s.downcase]&.dig(:modern) == true
  255. end
  256. # Instance methods for compression level info
  257. def compression_level_info
  258. @compression_config || COMPRESSION_LEVELS['lossy']
  259. end
  260. def compression_level_name
  261. @compression_level_name || 'lossy'
  262. end
  263. def expected_savings
  264. compression_level_info[:expected_savings]
  265. end
  266. def recommended_for
  267. compression_level_info[:recommended_for]
  268. end
  269. private
  270. def should_optimize?
  271. return false unless @medium.image?
  272. return false unless @upload.file.attached?
  273. return false unless @storage_config.auto_optimize_enabled?
  274. # Check if optimization is enabled in media settings
  275. SiteSetting.get('auto_optimize_images', false)
  276. end
  277. def load_settings
  278. # Get compression level setting
  279. compression_level_name = SiteSetting.get('image_compression_level', 'lossy')
  280. compression_config = COMPRESSION_LEVELS[compression_level_name] || COMPRESSION_LEVELS['lossy']
  281. # Apply compression level settings
  282. if compression_config[:quality]
  283. self.quality = compression_config[:quality]
  284. else
  285. # Use custom settings for custom level
  286. self.quality = SiteSetting.get('image_quality', 85).to_i
  287. end
  288. if compression_config[:compression_level]
  289. self.compression_level = compression_config[:compression_level]
  290. else
  291. # Use custom settings for custom level
  292. self.compression_level = SiteSetting.get('image_compression_level_value', 6).to_i
  293. end
  294. # Other settings
  295. self.max_width = SiteSetting.get('image_max_width', 2000).to_i
  296. self.max_height = SiteSetting.get('image_max_height', 2000).to_i
  297. self.strip_metadata = SiteSetting.get('strip_image_metadata', true)
  298. self.enable_webp = SiteSetting.get('enable_webp_variants', true)
  299. self.enable_avif = SiteSetting.get('enable_avif_variants', true)
  300. # Store compression level info
  301. @compression_level_name = compression_level_name
  302. @compression_config = compression_config
  303. end
  304. def process_image(file_data)
  305. require 'image_processing/vips'
  306. # Create a temporary file for processing
  307. temp_file = Tempfile.new(['original_input', '.jpg'])
  308. temp_file.binmode
  309. temp_file.write(file_data)
  310. temp_file.rewind
  311. processed = ImageProcessing::Vips
  312. .source(temp_file.path)
  313. .resize_to_limit(max_width, max_height)
  314. .saver(
  315. quality: quality,
  316. strip: strip_metadata,
  317. optimize: true,
  318. compression_level: compression_level # Apply compression level
  319. )
  320. result = processed.call
  321. File.read(result.path)
  322. rescue => e
  323. Rails.logger.warn "Image processing failed: #{e.message}"
  324. nil
  325. ensure
  326. temp_file&.close
  327. temp_file&.unlink
  328. File.unlink(result.path) if result && File.exist?(result.path)
  329. end
  330. def generate_format_variant(image_data, format)
  331. return nil unless image_data
  332. begin
  333. require 'image_processing/vips'
  334. temp_file = Tempfile.new(['variant_input', '.jpg'])
  335. temp_file.binmode
  336. temp_file.write(image_data)
  337. temp_file.rewind
  338. processed = ImageProcessing::Vips
  339. .source(temp_file.path)
  340. .convert(format)
  341. .saver(
  342. quality: quality,
  343. strip: strip_metadata,
  344. lossless: false,
  345. compression_level: compression_level
  346. )
  347. result = processed.call
  348. File.read(result.path)
  349. rescue => e
  350. Rails.logger.warn "#{format.upcase} variant generation failed: #{e.message}"
  351. nil
  352. ensure
  353. temp_file&.close
  354. temp_file&.unlink
  355. File.unlink(result.path) if result && File.exist?(result.path)
  356. end
  357. end
  358. def replace_file(new_file_data)
  359. # Delete old blob
  360. @upload.file.purge
  361. # Attach new blob
  362. @upload.file.attach(
  363. io: StringIO.new(new_file_data),
  364. filename: @upload.file.filename.to_s,
  365. content_type: @upload.file.content_type
  366. )
  367. # Update file size
  368. @upload.update!(file_size: new_file_data.size)
  369. end
  370. def store_variant(variant_data, format)
  371. return unless variant_data
  372. # Create variant blob
  373. variant_blob = ActiveStorage::Blob.create_and_upload!(
  374. io: StringIO.new(variant_data),
  375. filename: "#{@upload.file.filename.base}.#{format}",
  376. content_type: "image/#{format}"
  377. )
  378. # Store variant metadata in upload
  379. variants = @upload.variants || {}
  380. variants[format] = {
  381. blob_id: variant_blob.id,
  382. size: variant_data.size,
  383. created_at: Time.current
  384. }
  385. @upload.update!(variants: variants)
  386. end
  387. def generate_responsive_variant(image_data, width, format)
  388. return nil unless image_data
  389. begin
  390. require 'image_processing/vips'
  391. temp_file = Tempfile.new(['responsive_input', '.jpg'])
  392. temp_file.binmode
  393. temp_file.write(image_data)
  394. temp_file.rewind
  395. processed = ImageProcessing::Vips
  396. .source(temp_file.path)
  397. .resize_to_limit(width, width * 2) # Allow 2:1 aspect ratio
  398. .saver(
  399. quality: quality,
  400. strip: strip_metadata,
  401. optimize: true
  402. )
  403. # Convert to specific format if needed
  404. if format == 'webp'
  405. processed = processed.convert('webp').saver(lossless: false)
  406. elsif format == 'avif'
  407. processed = processed.convert('avif').saver(lossless: false)
  408. end
  409. result = processed.call
  410. File.read(result.path)
  411. rescue => e
  412. Rails.logger.warn "Responsive variant generation failed (#{format}, #{width}px): #{e.message}"
  413. nil
  414. ensure
  415. temp_file&.close
  416. temp_file&.unlink
  417. File.unlink(result.path) if result && File.exist?(result.path)
  418. end
  419. end
  420. def store_responsive_variant(variant_data, format, width)
  421. return unless variant_data
  422. # Create responsive variant blob
  423. extension = format == 'original' ? @upload.file.filename.extension : format
  424. variant_blob = ActiveStorage::Blob.create_and_upload!(
  425. io: StringIO.new(variant_data),
  426. filename: "#{@upload.file.filename.base}_#{width}w.#{extension}",
  427. content_type: format == 'original' ? @upload.file.content_type : "image/#{format}"
  428. )
  429. # Store responsive variant metadata in upload
  430. variants = @upload.variants || {}
  431. responsive_key = "#{format}_#{width}w"
  432. variants[responsive_key] = {
  433. blob_id: variant_blob.id,
  434. size: variant_data.size,
  435. width: width,
  436. format: format,
  437. created_at: Time.current
  438. }
  439. @upload.update!(variants: variants)
  440. end
  441. # Logging methods
  442. def create_log_entry
  443. @log_entry = ImageOptimizationLog.create!(
  444. medium: @medium,
  445. upload: @upload,
  446. user: @medium.user,
  447. tenant: @medium.tenant,
  448. filename: @upload.filename,
  449. content_type: @upload.content_type,
  450. compression_level: compression_level_name,
  451. quality: quality,
  452. strip_metadata: strip_metadata,
  453. enable_webp: enable_webp,
  454. enable_avif: enable_avif,
  455. optimization_type: @optimization_type,
  456. status: 'processing',
  457. processing_time: 0,
  458. storage_provider: @upload.storage_provider&.name,
  459. cdn_enabled: @storage_config.cdn_enabled?,
  460. user_agent: @request_context[:user_agent],
  461. ip_address: @request_context[:ip_address]
  462. )
  463. rescue => e
  464. Rails.logger.error "Failed to create log entry: #{e.message}"
  465. @log_entry = nil
  466. end
  467. def update_log_entry(attributes)
  468. return unless @log_entry
  469. processing_time = Time.current - @start_time
  470. @log_entry.update!(
  471. attributes.merge(
  472. processing_time: processing_time
  473. )
  474. )
  475. rescue => e
  476. Rails.logger.error "Failed to update log entry: #{e.message}"
  477. end
  478. def log_warning(message)
  479. return unless @log_entry
  480. warnings = @log_entry.warnings || []
  481. warnings << message
  482. @log_entry.update!(warnings: warnings)
  483. rescue => e
  484. Rails.logger.error "Failed to log warning: #{e.message}"
  485. end
  486. end

app/services/liquid_template_renderer.rb

16.67% lines covered

0.0% branches covered

66 relevant lines. 11 lines covered and 55 lines missed.
17 total branches, 0 branches covered and 17 branches missed.
    
  1. 1 class LiquidTemplateRenderer
  2. 1 def initialize(theme_name, template_type, template_data = {})
  3. @theme_name = theme_name
  4. @template_type = template_type
  5. @template_data = template_data
  6. @theme_path = Rails.root.join('app', 'themes', theme_name)
  7. end
  8. 1 def render
  9. # Load the template structure
  10. template_structure = load_template_structure
  11. # Render the layout
  12. layout_content = render_layout
  13. # Render sections in order
  14. sections_html = render_sections(template_structure)
  15. # Combine layout with sections
  16. layout_content.gsub('{{ content_for_layout }}', sections_html)
  17. end
  18. 1 def render_section(section_id, section_data)
  19. section_type = section_data['type']
  20. section_settings = section_data['settings'] || {}
  21. # Load section file
  22. section_file = @theme_path.join('sections', "#{section_type}.liquid")
  23. else: 0 then: 0 return '' unless File.exist?(section_file)
  24. section_content = File.read(section_file)
  25. # Create liquid template with section data
  26. template = Liquid::Template.parse(section_content)
  27. # Prepare context with section settings
  28. context = {
  29. 'section' => {
  30. 'settings' => section_settings,
  31. 'id' => section_id,
  32. 'type' => section_type
  33. }
  34. }
  35. # Add global theme settings
  36. context['settings'] = load_theme_settings
  37. # Render the section
  38. template.render(context)
  39. rescue => e
  40. Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
  41. "<div class='error'>Error rendering section: #{section_type}</div>"
  42. end
  43. 1 private
  44. 1 def load_template_structure
  45. template_file = @theme_path.join('templates', "#{@template_type}.json")
  46. then: 0 if File.exist?(template_file)
  47. JSON.parse(File.read(template_file))
  48. else: 0 else
  49. @template_data
  50. end
  51. end
  52. 1 def render_layout
  53. layout_file = @theme_path.join('layout', 'theme.liquid')
  54. then: 0 if File.exist?(layout_file)
  55. layout_content = File.read(layout_file)
  56. # Create liquid template
  57. template = Liquid::Template.parse(layout_content)
  58. # Prepare context
  59. context = {
  60. 'template' => @template_type,
  61. 'settings' => load_theme_settings,
  62. 'page' => load_page_data
  63. }
  64. # Render the layout
  65. template.render(context)
  66. else
  67. else: 0 # Default layout if theme.liquid doesn't exist
  68. default_layout
  69. end
  70. rescue => e
  71. Rails.logger.error "Error rendering layout: #{e.message}"
  72. default_layout
  73. end
  74. 1 def render_sections(template_structure)
  75. sections_html = ''
  76. then: 0 else: 0 if template_structure['order'] && template_structure['sections']
  77. template_structure['order'].each do |section_id|
  78. section_data = template_structure['sections'][section_id]
  79. then: 0 else: 0 if section_data
  80. sections_html += render_section(section_id, section_data)
  81. end
  82. end
  83. end
  84. sections_html
  85. end
  86. 1 def load_theme_settings
  87. settings_file = @theme_path.join('config', 'settings_schema.json')
  88. then: 0 if File.exist?(settings_file)
  89. settings_schema = JSON.parse(File.read(settings_file))
  90. # Convert schema to settings with defaults
  91. settings = {}
  92. settings_schema.each do |group|
  93. group['settings'].each do |setting|
  94. settings[setting['id']] = setting['default']
  95. end
  96. end
  97. settings
  98. else: 0 else
  99. {}
  100. end
  101. end
  102. 1 def load_page_data
  103. # Load page-specific data based on template type
  104. case @template_type
  105. when: 0 when 'index'
  106. {
  107. 'title' => 'Homepage',
  108. 'description' => 'Welcome to our site'
  109. }
  110. when: 0 when 'blog'
  111. {
  112. 'title' => 'Blog',
  113. 'description' => 'Latest posts'
  114. }
  115. when: 0 when 'page'
  116. {
  117. 'title' => 'Page',
  118. 'description' => 'Page content'
  119. }
  120. when: 0 when 'post'
  121. {
  122. 'title' => 'Blog Post',
  123. 'description' => 'Post content'
  124. }
  125. else: 0 else
  126. {
  127. 'title' => @template_type.humanize,
  128. 'description' => ''
  129. }
  130. end
  131. end
  132. 1 def default_layout
  133. <<~HTML
  134. <!DOCTYPE html>
  135. <html>
  136. <head>
  137. <title>{{ page.title }}</title>
  138. <meta name="description" content="{{ page.description }}">
  139. <link rel="stylesheet" href="/assets/theme.css">
  140. </head>
  141. <body>
  142. {{ content_for_layout }}
  143. <script src="/assets/theme.js"></script>
  144. </body>
  145. </html>
  146. HTML
  147. end
  148. end

app/services/liquid_template_version_renderer.rb

0.0% lines covered

100.0% branches covered

135 relevant lines. 0 lines covered and 135 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class LiquidTemplateVersionRenderer
  2. def initialize(theme_version, template_type)
  3. @theme_version = theme_version
  4. @template_type = template_type
  5. @theme_name = theme_version.theme_name
  6. end
  7. def render
  8. # Get template data from the theme version
  9. template_data = @theme_version.template_data(@template_type)
  10. # Render the layout with sections
  11. layout_content = render_layout
  12. # Render sections in order
  13. sections_html = render_sections(template_data)
  14. # Combine layout with sections
  15. layout_content.gsub('{{ content_for_layout }}', sections_html)
  16. end
  17. def render_section(section_id, section_data)
  18. section_type = section_data['type']
  19. section_settings = section_data['settings'] || {}
  20. # Get section content from theme version
  21. section_content = @theme_version.section_content(section_type)
  22. return '' if section_content.blank?
  23. # Create liquid template with section data
  24. template = Liquid::Template.parse(section_content)
  25. # Prepare context with section settings
  26. context = {
  27. 'section' => {
  28. 'settings' => section_settings,
  29. 'id' => section_id,
  30. 'type' => section_type
  31. }
  32. }
  33. # Add global theme settings
  34. context['settings'] = load_theme_settings
  35. # Add sample data for preview
  36. # No sample data needed - use real data
  37. # Render the section
  38. template.render(context)
  39. rescue => e
  40. Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
  41. "<div class='error'>Error rendering section: #{section_type}</div>"
  42. end
  43. private
  44. def render_layout
  45. layout_content = @theme_version.layout_content
  46. if layout_content.present?
  47. # Create liquid template
  48. template = Liquid::Template.parse(layout_content)
  49. # Prepare context
  50. context = {
  51. 'template' => @template_type,
  52. 'settings' => load_theme_settings,
  53. 'page' => load_page_data
  54. }
  55. # Add sample data
  56. # No sample data needed - use real data
  57. # Render the layout
  58. template.render(context)
  59. else
  60. # Default layout if theme.liquid doesn't exist
  61. default_layout
  62. end
  63. rescue => e
  64. Rails.logger.error "Error rendering layout: #{e.message}"
  65. default_layout
  66. end
  67. def render_sections(template_data)
  68. sections_html = ''
  69. if template_data['order'] && template_data['sections']
  70. template_data['order'].each do |section_id|
  71. section_data = template_data['sections'][section_id]
  72. if section_data
  73. sections_html += render_section(section_id, section_data)
  74. end
  75. end
  76. end
  77. sections_html
  78. end
  79. def load_theme_settings
  80. # Get theme settings from the theme version
  81. settings_file_content = @theme_version.file_content('config/settings_schema.json')
  82. if settings_file_content.present?
  83. settings_schema = JSON.parse(settings_file_content)
  84. # Convert schema to settings with defaults
  85. settings = {}
  86. settings_schema.each do |group|
  87. group['settings'].each do |setting|
  88. settings[setting['id']] = setting['default']
  89. end
  90. end
  91. settings
  92. else
  93. {}
  94. end
  95. rescue JSON::ParserError
  96. {}
  97. end
  98. def load_page_data
  99. # Load page-specific data based on template type
  100. case @template_type
  101. when 'index'
  102. {
  103. 'title' => 'Homepage',
  104. 'description' => 'Welcome to our site',
  105. 'posts' => []
  106. }
  107. when 'blog'
  108. {
  109. 'title' => 'Blog',
  110. 'description' => 'Latest posts',
  111. 'posts' => []
  112. }
  113. when 'page'
  114. {
  115. 'title' => 'Sample Page',
  116. 'description' => 'This is a sample page',
  117. 'content' => '<p>This is sample page content for preview.</p>'
  118. }
  119. when 'post'
  120. {
  121. 'title' => 'Sample Blog Post',
  122. 'description' => 'This is a sample blog post',
  123. 'content' => '<p>This is sample blog post content for preview.</p>',
  124. 'author' => 'Sample Author',
  125. 'date' => Time.current.strftime('%B %d, %Y')
  126. }
  127. else
  128. {
  129. 'title' => @template_type.humanize,
  130. 'description' => ''
  131. }
  132. end
  133. end
  134. def default_layout
  135. <<~HTML
  136. <!DOCTYPE html>
  137. <html>
  138. <head>
  139. <title>{{ page.title }}</title>
  140. <meta name="description" content="{{ page.description }}">
  141. <meta name="viewport" content="width=device-width, initial-scale=1">
  142. <style>
  143. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; background: #f9fafb; }
  144. .container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
  145. </style>
  146. </head>
  147. <body>
  148. <div class="container">
  149. {{ content_for_layout }}
  150. </div>
  151. </body>
  152. </html>
  153. HTML
  154. end
  155. end

app/services/maxmind_updater_service.rb

0.0% lines covered

100.0% branches covered

243 relevant lines. 0 lines covered and 243 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class MaxmindUpdaterService
  2. include Singleton
  3. MAXMIND_BASE_URL = 'https://download.maxmind.com/app/geoip_download'
  4. DATABASE_DIR = Rails.root.join('db', 'maxmind')
  5. DATABASES = {
  6. 'country' => {
  7. edition_id: 'GeoLite2-Country',
  8. filename: 'GeoLite2-Country.mmdb'
  9. },
  10. 'city' => {
  11. edition_id: 'GeoLite2-City',
  12. filename: 'GeoLite2-City.mmdb'
  13. }
  14. }.freeze
  15. def initialize
  16. ensure_database_directory
  17. end
  18. def update_database(type = 'country')
  19. return { success: false, message: 'MaxMind license key not configured' } unless license_key_configured?
  20. return { success: false, message: 'Invalid database type' } unless DATABASES.key?(type)
  21. database_info = DATABASES[type]
  22. download_url = build_download_url(database_info[:edition_id])
  23. target_path = DATABASE_DIR.join(database_info[:filename])
  24. temp_path = target_path.to_s + '.tmp'
  25. begin
  26. Rails.logger.info "Starting MaxMind #{type} database update..."
  27. # Download the database
  28. response = download_database(download_url)
  29. return { success: false, message: "Download failed: #{response[:error]}" } unless response[:success]
  30. # Write to temporary file
  31. File.write(temp_path, response[:data])
  32. # Verify the downloaded file
  33. unless valid_mmdb_file?(temp_path)
  34. File.delete(temp_path) if File.exist?(temp_path)
  35. return { success: false, message: 'Downloaded file is not a valid MMDB database' }
  36. end
  37. # Backup existing database if it exists
  38. if File.exist?(target_path)
  39. backup_path = target_path.to_s + ".backup.#{Time.current.to_i}"
  40. File.rename(target_path, backup_path)
  41. end
  42. # Move temp file to final location
  43. File.rename(temp_path, target_path)
  44. # Clean up backup if everything is successful
  45. if File.exist?(backup_path)
  46. File.delete(backup_path)
  47. end
  48. # Update last update timestamp
  49. update_last_update_timestamp(type)
  50. Rails.logger.info "MaxMind #{type} database updated successfully"
  51. { success: true, message: "#{type.capitalize} database updated successfully", path: target_path }
  52. rescue => e
  53. Rails.logger.error "MaxMind database update failed: #{e.message}"
  54. # Clean up temp file
  55. File.delete(temp_path) if File.exist?(temp_path)
  56. { success: false, message: "Update failed: #{e.message}" }
  57. end
  58. end
  59. def update_all_databases
  60. results = {}
  61. DATABASES.keys.each do |type|
  62. results[type] = update_database(type)
  63. end
  64. results
  65. end
  66. def check_database_age(type = 'country')
  67. return { error: 'Invalid database type' } unless DATABASES.key?(type)
  68. database_info = DATABASES[type]
  69. target_path = DATABASE_DIR.join(database_info[:filename])
  70. if File.exist?(target_path)
  71. stat = File.stat(target_path)
  72. age_days = (Time.current - stat.mtime) / 1.day
  73. {
  74. exists: true,
  75. age_days: age_days.round(1),
  76. last_modified: stat.mtime,
  77. size: stat.size,
  78. needs_update: age_days > 30 # MaxMind recommends updating monthly
  79. }
  80. else
  81. {
  82. exists: false,
  83. needs_update: true
  84. }
  85. end
  86. end
  87. def schedule_auto_update
  88. return { success: false, message: 'Auto-update not enabled' } unless auto_update_enabled?
  89. # Check if databases need updating
  90. needs_update = false
  91. DATABASES.keys.each do |type|
  92. age_info = check_database_age(type)
  93. if age_info[:needs_update]
  94. needs_update = true
  95. break
  96. end
  97. end
  98. return { success: true, message: 'Databases are up to date' } unless needs_update
  99. # Update databases in background
  100. Thread.new do
  101. begin
  102. update_all_databases
  103. Rails.logger.info "Scheduled MaxMind database update completed"
  104. rescue => e
  105. Rails.logger.error "Scheduled MaxMind database update failed: #{e.message}"
  106. end
  107. end
  108. { success: true, message: 'Auto-update scheduled' }
  109. end
  110. def test_connection
  111. return { success: false, message: 'MaxMind license key not configured' } unless license_key_configured?
  112. begin
  113. # Test download with a small request
  114. test_url = build_download_url('GeoLite2-Country', suffix: '.tar.gz')
  115. response = HTTP.timeout(10).get(test_url)
  116. if response.status == 200
  117. { success: true, message: 'Connection to MaxMind successful' }
  118. else
  119. { success: false, message: "HTTP #{response.status}: #{response.reason}" }
  120. end
  121. rescue => e
  122. { success: false, message: "Connection failed: #{e.message}" }
  123. end
  124. end
  125. def database_info
  126. info = {}
  127. DATABASES.each do |type, config|
  128. target_path = DATABASE_DIR.join(config[:filename])
  129. if File.exist?(target_path)
  130. stat = File.stat(target_path)
  131. info[type] = {
  132. exists: true,
  133. path: target_path,
  134. size: stat.size,
  135. last_modified: stat.mtime,
  136. age_days: ((Time.current - stat.mtime) / 1.day).round(1),
  137. needs_update: (Time.current - stat.mtime) > 30.days
  138. }
  139. else
  140. info[type] = {
  141. exists: false,
  142. needs_update: true
  143. }
  144. end
  145. end
  146. info
  147. end
  148. private
  149. def ensure_database_directory
  150. FileUtils.mkdir_p(DATABASE_DIR) unless Dir.exist?(DATABASE_DIR)
  151. end
  152. def license_key_configured?
  153. SiteSetting.get('maxmind_license_key', '').present?
  154. end
  155. def auto_update_enabled?
  156. SiteSetting.get('maxmind_auto_update', false)
  157. end
  158. def build_download_url(edition_id, suffix = '.mmdb')
  159. license_key = SiteSetting.get('maxmind_license_key', '')
  160. "#{MAXMIND_BASE_URL}?edition_id=#{edition_id}&license_key=#{license_key}&suffix=#{suffix}"
  161. end
  162. def download_database(url)
  163. begin
  164. response = HTTP.timeout(30).get(url)
  165. if response.status == 200
  166. { success: true, data: response.body.to_s }
  167. else
  168. { success: false, error: "HTTP #{response.status}: #{response.reason}" }
  169. end
  170. rescue => e
  171. { success: false, error: e.message }
  172. end
  173. end
  174. def valid_mmdb_file?(file_path)
  175. begin
  176. # Try to open the file with MaxMindDB to verify it's valid
  177. db = MaxMindDB.new(file_path)
  178. # If we can create the object without error, it's likely valid
  179. true
  180. rescue => e
  181. Rails.logger.error "Invalid MMDB file: #{e.message}"
  182. false
  183. end
  184. end
  185. def update_last_update_timestamp(type)
  186. SiteSetting.set("maxmind_#{type}_last_update", Time.current.iso8601)
  187. end
  188. # Scheduling methods for automatic updates
  189. def schedule_auto_update(frequency = 'weekly')
  190. # Schedule automatic updates using Sidekiq-Cron
  191. cron_schedule = case frequency
  192. when 'daily'
  193. '0 2 * * *' # Daily at 2 AM
  194. when 'weekly'
  195. '0 2 * * 1' # Weekly on Monday at 2 AM
  196. when 'monthly'
  197. '0 2 1 * *' # Monthly on 1st at 2 AM
  198. else
  199. '0 2 * * 1' # Default to weekly
  200. end
  201. # Remove existing job if it exists
  202. Sidekiq::Cron::Job.destroy('MaxMind Database Update')
  203. # Create new scheduled job
  204. Sidekiq::Cron::Job.create(
  205. name: 'MaxMind Database Update',
  206. cron: cron_schedule,
  207. class: 'MaxmindUpdateJob',
  208. args: ['full'],
  209. description: "Automatic MaxMind database update (#{frequency})"
  210. )
  211. # Store the schedule preference
  212. SiteSetting.set('maxmind_update_frequency', frequency)
  213. SiteSetting.set('maxmind_auto_update_enabled', true)
  214. Rails.logger.info "MaxMind automatic update scheduled: #{frequency} (#{cron_schedule})"
  215. end
  216. def disable_auto_update
  217. # Remove the scheduled job
  218. Sidekiq::Cron::Job.destroy('MaxMind Database Update')
  219. # Update settings
  220. SiteSetting.set('maxmind_auto_update_enabled', false)
  221. Rails.logger.info "MaxMind automatic update disabled"
  222. end
  223. def get_update_schedule_info
  224. job = Sidekiq::Cron::Job.find('MaxMind Database Update')
  225. if job
  226. {
  227. enabled: true,
  228. frequency: SiteSetting.get('maxmind_update_frequency', 'weekly'),
  229. next_run: job.next_time,
  230. last_run: get_last_update_time,
  231. cron_schedule: job.cron
  232. }
  233. else
  234. {
  235. enabled: false,
  236. frequency: nil,
  237. next_run: nil,
  238. last_run: get_last_update_time,
  239. cron_schedule: nil
  240. }
  241. end
  242. end
  243. def get_last_update_time
  244. last_update = SiteSetting.get('maxmind_last_update')
  245. last_update ? Time.parse(last_update) : nil
  246. end
  247. def check_and_update_if_needed
  248. # Check if databases need updating and update if necessary
  249. needs_update = false
  250. DATABASES.keys.each do |type|
  251. age_info = check_database_age(type)
  252. if age_info[:needs_update]
  253. needs_update = true
  254. break
  255. end
  256. end
  257. if needs_update
  258. Rails.logger.info "MaxMind databases need updating, starting automatic update"
  259. MaxmindUpdateJob.perform_later('full')
  260. else
  261. Rails.logger.info "MaxMind databases are up to date"
  262. end
  263. end
  264. end

app/services/oauth_provider_service.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class OauthProviderService
  2. def self.configure_providers
  3. # This method will be called to configure OAuth providers dynamically
  4. # It will be used by the OAuth controller when settings are updated
  5. # Clear existing providers
  6. Rails.application.config.middleware.delete(OmniAuth::Builder)
  7. # Add new providers based on current settings
  8. Rails.application.config.middleware.use OmniAuth::Builder do
  9. # Google OAuth
  10. if SiteSetting.get('google_oauth_enabled', false) &&
  11. SiteSetting.get('google_oauth_client_id', '').present? &&
  12. SiteSetting.get('google_oauth_client_secret', '').present?
  13. provider :google_oauth2,
  14. SiteSetting.get('google_oauth_client_id', ''),
  15. SiteSetting.get('google_oauth_client_secret', ''),
  16. {
  17. name: 'google',
  18. scope: 'email,profile',
  19. prompt: 'select_account',
  20. access_type: 'offline',
  21. hd: SiteSetting.get('google_oauth_tenant', '').presence
  22. }
  23. end
  24. # GitHub OAuth
  25. if SiteSetting.get('github_oauth_enabled', false) &&
  26. SiteSetting.get('github_oauth_client_id', '').present? &&
  27. SiteSetting.get('github_oauth_client_secret', '').present?
  28. provider :github,
  29. SiteSetting.get('github_oauth_client_id', ''),
  30. SiteSetting.get('github_oauth_client_secret', ''),
  31. {
  32. scope: 'user:email'
  33. }
  34. end
  35. # Facebook OAuth
  36. if SiteSetting.get('facebook_oauth_enabled', false) &&
  37. SiteSetting.get('facebook_oauth_app_id', '').present? &&
  38. SiteSetting.get('facebook_oauth_app_secret', '').present?
  39. provider :facebook,
  40. SiteSetting.get('facebook_oauth_app_id', ''),
  41. SiteSetting.get('facebook_oauth_app_secret', ''),
  42. {
  43. scope: 'email',
  44. info_fields: 'email,name'
  45. }
  46. end
  47. # Twitter OAuth
  48. if SiteSetting.get('twitter_oauth_enabled', false) &&
  49. SiteSetting.get('twitter_oauth_api_key', '').present? &&
  50. SiteSetting.get('twitter_oauth_api_secret', '').present?
  51. provider :twitter,
  52. SiteSetting.get('twitter_oauth_api_key', ''),
  53. SiteSetting.get('twitter_oauth_api_secret', '')
  54. end
  55. end
  56. end
  57. def self.get_available_providers
  58. providers = []
  59. providers << 'google' if SiteSetting.get('google_oauth_enabled', false) &&
  60. SiteSetting.get('google_oauth_client_id', '').present?
  61. providers << 'github' if SiteSetting.get('github_oauth_enabled', false) &&
  62. SiteSetting.get('github_oauth_client_id', '').present?
  63. providers << 'facebook' if SiteSetting.get('facebook_oauth_enabled', false) &&
  64. SiteSetting.get('facebook_oauth_app_id', '').present?
  65. providers << 'twitter' if SiteSetting.get('twitter_oauth_enabled', false) &&
  66. SiteSetting.get('twitter_oauth_api_key', '').present?
  67. providers
  68. end
  69. def self.provider_enabled?(provider)
  70. case provider
  71. when 'google'
  72. SiteSetting.get('google_oauth_enabled', false) &&
  73. SiteSetting.get('google_oauth_client_id', '').present?
  74. when 'github'
  75. SiteSetting.get('github_oauth_enabled', false) &&
  76. SiteSetting.get('github_oauth_client_id', '').present?
  77. when 'facebook'
  78. SiteSetting.get('facebook_oauth_enabled', false) &&
  79. SiteSetting.get('facebook_oauth_app_id', '').present?
  80. when 'twitter'
  81. SiteSetting.get('twitter_oauth_enabled', false) &&
  82. SiteSetting.get('twitter_oauth_api_key', '').present?
  83. else
  84. false
  85. end
  86. end
  87. end

app/services/plugin_reload_service.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PluginReloadService
  2. def self.reload_app_for_plugin_change(plugin_name, action)
  3. return unless Rails.env.development?
  4. Rails.logger.info "🔄 Plugin #{action}: #{plugin_name} - Triggering hot reload..."
  5. # Store the reload data in the session for JavaScript to pick up
  6. Thread.current[:plugin_reload_data] = {
  7. plugin_name: plugin_name,
  8. action: action,
  9. timestamp: Time.current.to_i,
  10. trigger_reload: true
  11. }
  12. # Also create a file trigger for any background processes
  13. trigger_file = Rails.root.join('tmp', 'plugin_reload_trigger')
  14. File.write(trigger_file, {
  15. plugin_name: plugin_name,
  16. action: action,
  17. timestamp: Time.current.to_i
  18. }.to_json)
  19. Rails.logger.info "✅ Hot reload triggered for plugin #{action}: #{plugin_name}"
  20. end
  21. def self.get_reload_data
  22. data = Thread.current[:plugin_reload_data]
  23. Thread.current[:plugin_reload_data] = nil # Clear after reading
  24. data
  25. end
  26. def self.trigger_hot_reload(plugin_name, action)
  27. # This will be called from JavaScript to trigger the actual reload
  28. Rails.logger.info "🔥 Hot reloading for plugin #{action}: #{plugin_name}"
  29. # In a real hot reload system, this would:
  30. # 1. Reload the plugin system
  31. # 2. Update the UI without full page refresh
  32. # 3. Show a success animation
  33. # For now, we'll trigger a page reload with a cool animation
  34. true
  35. end
  36. end

app/services/post_by_email_service.rb

0.0% lines covered

100.0% branches covered

160 relevant lines. 0 lines covered and 160 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. require 'net/imap'
  2. require 'mail'
  3. class PostByEmailService
  4. class << self
  5. def check_mail
  6. return { new_posts: 0, checked: 0 } unless enabled?
  7. new_posts = 0
  8. checked = 0
  9. imap = connect_to_imap
  10. begin
  11. imap.select(folder)
  12. # Search for unread emails
  13. message_ids = imap.search(['NOT', 'SEEN'])
  14. checked = message_ids.length
  15. Rails.logger.info "Found #{checked} unread email(s) in #{folder}"
  16. message_ids.each do |message_id|
  17. begin
  18. # Fetch the email
  19. msg_data = imap.fetch(message_id, 'RFC822')[0]
  20. email = Mail.read_from_string(msg_data.attr['RFC822'])
  21. # Create post from email
  22. if create_post_from_email(email)
  23. new_posts += 1
  24. # Mark as read if configured
  25. if mark_as_read?
  26. imap.store(message_id, "+FLAGS", [:Seen])
  27. end
  28. # Delete if configured
  29. if delete_after_import?
  30. imap.store(message_id, "+FLAGS", [:Deleted])
  31. end
  32. end
  33. rescue => e
  34. Rails.logger.error "Error processing email #{message_id}: #{e.message}"
  35. Rails.logger.error e.backtrace.join("\n")
  36. end
  37. end
  38. # Expunge deleted messages
  39. imap.expunge if delete_after_import?
  40. ensure
  41. imap.disconnect if imap
  42. end
  43. { new_posts: new_posts, checked: checked }
  44. end
  45. private
  46. def enabled?
  47. SiteSetting.get('post_by_email_enabled', false)
  48. end
  49. def server
  50. SiteSetting.get('imap_server', '')
  51. end
  52. def port
  53. SiteSetting.get('imap_port', '993').to_i
  54. end
  55. def email
  56. SiteSetting.get('imap_email', '')
  57. end
  58. def password
  59. SiteSetting.get('imap_password', '')
  60. end
  61. def ssl?
  62. SiteSetting.get('imap_ssl', 'true') == 'true'
  63. end
  64. def folder
  65. SiteSetting.get('imap_folder', 'INBOX')
  66. end
  67. def mark_as_read?
  68. SiteSetting.get('post_by_email_mark_as_read', true)
  69. end
  70. def delete_after_import?
  71. SiteSetting.get('post_by_email_delete_after_import', false)
  72. end
  73. def default_category_id
  74. SiteSetting.get('post_by_email_default_category', nil)
  75. end
  76. def default_author_id
  77. SiteSetting.get('post_by_email_default_author', User.first&.id)
  78. end
  79. def connect_to_imap
  80. imap = Net::IMAP.new(server, port: port, ssl: ssl?)
  81. imap.login(email, password)
  82. imap
  83. rescue => e
  84. Rails.logger.error "Failed to connect to IMAP server: #{e.message}"
  85. raise "IMAP connection failed: #{e.message}"
  86. end
  87. def create_post_from_email(email)
  88. # Extract subject as title
  89. title = email.subject.presence || "Post from #{email.from.first}"
  90. # Extract body
  91. body_html = extract_body(email)
  92. # Find or create author
  93. author = User.find_by(id: default_author_id) || User.first
  94. unless author
  95. Rails.logger.error "No author found for post by email"
  96. return false
  97. end
  98. # Create the post
  99. post = Post.new(
  100. title: title,
  101. body_html: body_html,
  102. status: 'draft', # Always create as draft
  103. user_id: author.id,
  104. excerpt: generate_excerpt(body_html),
  105. created_at: email.date || Time.current
  106. )
  107. # Assign category if configured
  108. if default_category_id.present?
  109. category = Term.for_taxonomy('category').find_by(id: default_category_id)
  110. post.categories << category if category
  111. end
  112. if post.save
  113. Rails.logger.info "Created post ##{post.id} from email: #{title}"
  114. # Handle attachments
  115. process_attachments(email, post) if email.attachments.any?
  116. true
  117. else
  118. Rails.logger.error "Failed to create post from email: #{post.errors.full_messages.join(', ')}"
  119. false
  120. end
  121. rescue => e
  122. Rails.logger.error "Error creating post from email: #{e.message}"
  123. Rails.logger.error e.backtrace.join("\n")
  124. false
  125. end
  126. def extract_body(email)
  127. if email.html_part
  128. # Prefer HTML if available
  129. email.html_part.decoded
  130. elsif email.text_part
  131. # Convert plain text to HTML
  132. text = email.text_part.decoded
  133. text.gsub(/\n/, '<br>')
  134. elsif email.body
  135. # Fallback to body
  136. body_content = email.body.decoded
  137. # Check if it's HTML
  138. if body_content =~ /<[^>]+>/
  139. body_content
  140. else
  141. # Convert plain text to HTML
  142. body_content.gsub(/\n/, '<br>')
  143. end
  144. else
  145. ''
  146. end
  147. rescue => e
  148. Rails.logger.error "Error extracting email body: #{e.message}"
  149. ''
  150. end
  151. def generate_excerpt(html)
  152. # Strip HTML tags and get first 150 characters
  153. text = ActionView::Base.full_sanitizer.sanitize(html)
  154. text.truncate(150, separator: ' ')
  155. end
  156. def process_attachments(email, post)
  157. email.attachments.each do |attachment|
  158. begin
  159. # Skip if not an image (you can extend this to handle other types)
  160. next unless attachment.content_type.start_with?('image/')
  161. # Create a temporary file
  162. tempfile = Tempfile.new([attachment.filename, File.extname(attachment.filename)])
  163. tempfile.binmode
  164. tempfile.write(attachment.decoded)
  165. tempfile.rewind
  166. # Attach to post using ActiveStorage
  167. post.featured_image.attach(
  168. io: tempfile,
  169. filename: attachment.filename,
  170. content_type: attachment.content_type
  171. )
  172. tempfile.close
  173. tempfile.unlink
  174. Rails.logger.info "Attached #{attachment.filename} to post ##{post.id}"
  175. rescue => e
  176. Rails.logger.error "Error processing attachment #{attachment.filename}: #{e.message}"
  177. end
  178. end
  179. end
  180. end
  181. end

app/services/realtime_analytics_service.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class RealtimeAnalyticsService
  2. def self.broadcast_new_pageview(pageview)
  3. data = {
  4. type: 'new_pageview',
  5. pageview: {
  6. id: pageview.id,
  7. path: pageview.path,
  8. title: pageview.title,
  9. country: pageview.country_name,
  10. browser: pageview.browser,
  11. device: pageview.device,
  12. created_at: pageview.created_at.iso8601
  13. },
  14. stats: {
  15. active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  16. current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  17. unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
  18. active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name)
  19. },
  20. timestamp: Time.current.iso8601
  21. }
  22. ActionCable.server.broadcast('realtime_analytics', data)
  23. end
  24. def self.broadcast_stats_update
  25. data = {
  26. type: 'stats_update',
  27. stats: {
  28. active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  29. current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
  30. unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
  31. active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name)
  32. },
  33. recent_views: Pageview.where(created_at: 10.minutes.ago..Time.current)
  34. .order(created_at: :desc)
  35. .limit(10)
  36. .map do |pv|
  37. {
  38. path: pv.path,
  39. country: pv.country_name,
  40. browser: pv.browser,
  41. device: pv.device,
  42. created_at: pv.created_at.iso8601
  43. }
  44. end,
  45. timestamp: Time.current.iso8601
  46. }
  47. ActionCable.server.broadcast('realtime_analytics', data)
  48. end
  49. end

app/services/screenshot_service.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. require 'ferrum'
  2. class ScreenshotService
  3. attr_reader :url, :width, :height, :format
  4. def initialize(url, options = {})
  5. @url = url
  6. @width = options[:width] || 1200
  7. @height = options[:height] || 800
  8. @format = options[:format] || :png
  9. end
  10. def capture
  11. Rails.logger.info "ScreenshotService: Capturing screenshot of #{@url}"
  12. browser = Ferrum::Browser.new(
  13. headless: true,
  14. window_size: [@width, @height],
  15. timeout: 15, # Reduced timeout
  16. process_timeout: 10, # Add process timeout
  17. slow_mo: 0, # No slow motion
  18. browser_options: {
  19. 'no-sandbox' => nil,
  20. 'disable-dev-shm-usage' => nil,
  21. 'disable-gpu' => nil,
  22. 'disable-extensions' => nil,
  23. 'disable-plugins' => nil,
  24. 'disable-web-security' => nil,
  25. 'disable-features' => 'VizDisplayCompositor'
  26. }
  27. )
  28. begin
  29. Rails.logger.info "ScreenshotService: Browser created, navigating to #{@url}"
  30. # Navigate with reduced timeout
  31. browser.go_to(@url)
  32. # Check if we got redirected to login page
  33. current_url = browser.current_url
  34. Rails.logger.info "ScreenshotService: Current URL after navigation: #{current_url}"
  35. if current_url.include?('/auth/sign_in')
  36. Rails.logger.info "ScreenshotService: Redirected to login, this is expected for admin routes"
  37. raise "Cannot capture screenshot of admin route - authentication required"
  38. end
  39. # Wait for page to fully load
  40. Rails.logger.info "ScreenshotService: Waiting for page to load completely"
  41. sleep(2) # Give the page time to render
  42. # Take screenshot
  43. Rails.logger.info "ScreenshotService: Taking screenshot with format #{@format}"
  44. screenshot_data = browser.screenshot(
  45. format: @format,
  46. full: false
  47. )
  48. Rails.logger.info "ScreenshotService: Screenshot captured successfully"
  49. screenshot_data
  50. rescue => e
  51. Rails.logger.error "ScreenshotService: Error during capture - #{e.message}"
  52. Rails.logger.error e.backtrace.join("\n")
  53. raise e
  54. ensure
  55. browser.quit
  56. end
  57. end
  58. def capture_and_save(file_path)
  59. screenshot_data = capture
  60. # Ensure directory exists
  61. FileUtils.mkdir_p(File.dirname(file_path))
  62. # Save screenshot
  63. File.write(file_path, screenshot_data)
  64. file_path
  65. end
  66. # Capture theme preview screenshot
  67. def self.capture_theme_preview(theme_name, options = {})
  68. url = Rails.application.routes.url_helpers.preview_admin_themes_url(theme: theme_name, host: 'localhost:3000')
  69. screenshot_service = new(url, options)
  70. # Generate filename
  71. filename = "screenshot_#{theme_name.downcase}_#{Time.current.strftime('%Y%m%d_%H%M%S')}.#{options[:format] || 'png'}"
  72. file_path = Rails.root.join('tmp', 'screenshots', filename)
  73. screenshot_service.capture_and_save(file_path)
  74. end
  75. # Capture theme screenshot and return data directly (no filesystem storage)
  76. def self.capture_theme_screenshot_data(theme, options = {})
  77. Rails.logger.info "ScreenshotService: capture_theme_screenshot_data called with theme: #{theme.inspect}"
  78. return nil unless theme
  79. # Handle both Theme model objects and hash objects
  80. theme_id = theme.respond_to?(:id) ? theme.id : theme[:id]
  81. theme_name = theme.respond_to?(:name) ? theme.name : theme[:name]
  82. Rails.logger.info "ScreenshotService: Theme ID: #{theme_id}, Name: #{theme_name}"
  83. # Use optimized options for faster screenshots
  84. optimized_options = {
  85. width: 800, # Smaller width for faster processing
  86. height: 600, # Smaller height for faster processing
  87. format: :png
  88. }.merge(options)
  89. # Use the working public preview route with theme ID
  90. public_preview_url = Rails.application.routes.url_helpers.theme_preview_url(host: 'localhost:3000', id: theme_id)
  91. Rails.logger.info "ScreenshotService: Using public preview URL: #{public_preview_url}"
  92. screenshot_service = new(public_preview_url, optimized_options)
  93. screenshot_service.capture
  94. end
  95. end

app/services/storage_configuration_service.rb

0.0% lines covered

100.0% branches covered

121 relevant lines. 0 lines covered and 121 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class StorageConfigurationService
  2. attr_reader :storage_settings, :tenant
  3. def initialize(tenant = nil)
  4. @tenant = tenant || ActsAsTenant.current_tenant
  5. @storage_settings = load_storage_settings
  6. end
  7. # Configure ActiveStorage based on storage settings
  8. def configure_active_storage
  9. case storage_settings[:storage_type]
  10. when 's3'
  11. configure_s3_storage
  12. when 'local'
  13. configure_local_storage
  14. else
  15. configure_local_storage # Default fallback
  16. end
  17. end
  18. # Get the appropriate storage service name
  19. def storage_service_name
  20. case storage_settings[:storage_type]
  21. when 's3'
  22. 'amazon'
  23. when 'local'
  24. 'local'
  25. else
  26. 'local'
  27. end
  28. end
  29. # Get the storage root path for local storage
  30. def local_storage_root
  31. storage_settings[:local_storage_path] || Rails.root.join('storage').to_s
  32. end
  33. # Check if CDN is enabled
  34. def cdn_enabled?
  35. storage_settings[:enable_cdn] && storage_settings[:cdn_url].present?
  36. end
  37. # Get CDN URL
  38. def cdn_url
  39. storage_settings[:cdn_url] if cdn_enabled?
  40. end
  41. # Check if auto-optimization is enabled
  42. def auto_optimize_enabled?
  43. storage_settings[:auto_optimize_uploads]
  44. end
  45. # Get max file size in bytes
  46. def max_file_size_bytes
  47. storage_settings[:max_file_size] * 1024 * 1024 # Convert MB to bytes
  48. end
  49. # Get allowed file types as array
  50. def allowed_file_types
  51. return [] unless storage_settings[:allowed_file_types].present?
  52. storage_settings[:allowed_file_types].split(',').map(&:strip).map(&:downcase)
  53. end
  54. # Validate file against storage settings
  55. def file_allowed?(file)
  56. return false if file.nil?
  57. # Check file size
  58. return false if file.size > max_file_size_bytes
  59. # Check file extension
  60. extension = File.extname(file.original_filename).downcase.gsub('.', '')
  61. return false unless allowed_file_types.include?(extension)
  62. true
  63. end
  64. # Get S3 configuration
  65. def s3_config
  66. return {} unless storage_settings[:storage_type] == 's3'
  67. {
  68. service: 'S3',
  69. access_key_id: storage_settings[:storage_access_key],
  70. secret_access_key: storage_settings[:storage_secret_key],
  71. region: storage_settings[:storage_region] || 'us-east-1',
  72. bucket: storage_settings[:storage_bucket],
  73. endpoint: storage_settings[:storage_endpoint],
  74. path: storage_settings[:storage_path]
  75. }.compact
  76. end
  77. # Update storage.yml configuration
  78. def update_storage_config
  79. storage_yml_path = Rails.root.join('config', 'storage.yml')
  80. # Read current configuration
  81. current_config = File.exist?(storage_yml_path) ? YAML.load_file(storage_yml_path) : {}
  82. # Update configuration based on storage type
  83. case storage_settings[:storage_type]
  84. when 's3'
  85. current_config['amazon'] = s3_config
  86. current_config['local'] = {
  87. 'service' => 'Disk',
  88. 'root' => local_storage_root
  89. }
  90. when 'local'
  91. current_config['local'] = {
  92. 'service' => 'Disk',
  93. 'root' => local_storage_root
  94. }
  95. end
  96. # Write updated configuration
  97. File.write(storage_yml_path, current_config.to_yaml)
  98. # Reload ActiveStorage configuration
  99. Rails.application.config.active_storage.service = storage_service_name.to_sym
  100. end
  101. private
  102. def load_storage_settings
  103. # Get current tenant storage settings if available
  104. tenant_settings = {}
  105. if @tenant
  106. tenant_settings = {
  107. storage_type: @tenant.storage_type || 'local',
  108. storage_bucket: @tenant.storage_bucket,
  109. storage_region: @tenant.storage_region,
  110. storage_access_key: @tenant.storage_access_key,
  111. storage_secret_key: @tenant.storage_secret_key,
  112. storage_endpoint: @tenant.storage_endpoint,
  113. storage_path: @tenant.storage_path
  114. }
  115. end
  116. # Merge with SiteSetting values
  117. {
  118. # Storage Type
  119. storage_type: tenant_settings[:storage_type] || SiteSetting.get('storage_type', 'local'),
  120. # Local Storage Configuration
  121. local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
  122. # S3 Configuration
  123. storage_bucket: tenant_settings[:storage_bucket] || SiteSetting.get('storage_bucket', ''),
  124. storage_region: tenant_settings[:storage_region] || SiteSetting.get('storage_region', 'us-east-1'),
  125. storage_access_key: tenant_settings[:storage_access_key] || SiteSetting.get('storage_access_key', ''),
  126. storage_secret_key: tenant_settings[:storage_secret_key] || SiteSetting.get('storage_secret_key', ''),
  127. storage_endpoint: tenant_settings[:storage_endpoint] || SiteSetting.get('storage_endpoint', ''),
  128. storage_path: tenant_settings[:storage_path] || SiteSetting.get('storage_path', ''),
  129. # General Storage Settings
  130. enable_cdn: SiteSetting.get('enable_cdn', false),
  131. cdn_url: SiteSetting.get('cdn_url', ''),
  132. auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true),
  133. max_file_size: SiteSetting.get('max_file_size', 10).to_i, # MB
  134. allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3')
  135. }
  136. end
  137. def configure_s3_storage
  138. # S3 configuration is handled by the s3_config method
  139. # This could be extended to set up S3-specific settings
  140. end
  141. def configure_local_storage
  142. # Ensure local storage directory exists
  143. storage_path = local_storage_root
  144. FileUtils.mkdir_p(storage_path) unless File.directory?(storage_path)
  145. # Set proper permissions
  146. FileUtils.chmod(0755, storage_path)
  147. end
  148. end

app/services/theme_file_manager.rb

0.0% lines covered

100.0% branches covered

218 relevant lines. 0 lines covered and 218 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeFileManager
  2. # Allowed file extensions for editing
  3. EDITABLE_EXTENSIONS = %w[
  4. .erb .html .htm .haml .slim .liquid .php
  5. .css .scss .sass
  6. .js .coffee
  7. .json .yml .yaml
  8. .rb
  9. .md .txt
  10. ].freeze
  11. # Binary/asset extensions (download only)
  12. BINARY_EXTENSIONS = %w[
  13. .png .jpg .jpeg .gif .svg .webp .ico
  14. .woff .woff2 .ttf .eot .otf
  15. .mp4 .webm .ogg
  16. .zip .tar .gz
  17. .pdf
  18. ].freeze
  19. attr_reader :theme_name, :theme_path, :errors
  20. def initialize(theme_name)
  21. @theme_name = theme_name
  22. @theme_path = Rails.root.join('app', 'themes', theme_name)
  23. @errors = []
  24. validate_theme_exists!
  25. end
  26. # List all files in theme directory
  27. def list_files(directory = '')
  28. return [] unless valid_directory?(directory)
  29. full_path = @theme_path.join(directory)
  30. entries = []
  31. Dir.entries(full_path).sort.each do |entry|
  32. next if entry.start_with?('.')
  33. entry_path = full_path.join(entry)
  34. relative_path = entry_path.relative_path_from(@theme_path).to_s
  35. entries << {
  36. name: entry,
  37. path: relative_path,
  38. type: File.directory?(entry_path) ? 'directory' : 'file',
  39. editable: editable_file?(entry),
  40. extension: File.extname(entry),
  41. size: File.directory?(entry_path) ? nil : File.size(entry_path),
  42. modified_at: File.mtime(entry_path)
  43. }
  44. end
  45. entries
  46. end
  47. # Get file tree structure
  48. def file_tree
  49. build_tree(@theme_path)
  50. end
  51. # Read file content
  52. def read_file(file_path)
  53. return nil unless valid_file_path?(file_path)
  54. full_path = @theme_path.join(file_path)
  55. unless File.exist?(full_path)
  56. @errors << "File not found: #{file_path}"
  57. return nil
  58. end
  59. unless editable_file?(file_path)
  60. @errors << "File type not editable: #{file_path}"
  61. return nil
  62. end
  63. File.read(full_path)
  64. end
  65. # Write file content
  66. def write_file(file_path, content)
  67. return false unless valid_file_path?(file_path)
  68. full_path = @theme_path.join(file_path)
  69. unless editable_file?(file_path)
  70. @errors << "File type not editable: #{file_path}"
  71. return false
  72. end
  73. # Create backup before writing
  74. create_backup(full_path) if File.exist?(full_path)
  75. # Ensure directory exists
  76. FileUtils.mkdir_p(File.dirname(full_path))
  77. # Write file
  78. File.write(full_path, content)
  79. # Create version record
  80. create_version_record(file_path, content)
  81. true
  82. rescue => e
  83. @errors << "Failed to write file: #{e.message}"
  84. false
  85. end
  86. # Create new file
  87. def create_file(file_path, content = '')
  88. return false unless valid_file_path?(file_path)
  89. full_path = @theme_path.join(file_path)
  90. if File.exist?(full_path)
  91. @errors << "File already exists: #{file_path}"
  92. return false
  93. end
  94. write_file(file_path, content)
  95. end
  96. # Delete file
  97. def delete_file(file_path)
  98. return false unless valid_file_path?(file_path)
  99. full_path = @theme_path.join(file_path)
  100. unless File.exist?(full_path)
  101. @errors << "File not found: #{file_path}"
  102. return false
  103. end
  104. # Create backup before deleting
  105. create_backup(full_path)
  106. File.delete(full_path)
  107. true
  108. rescue => e
  109. @errors << "Failed to delete file: #{e.message}"
  110. false
  111. end
  112. # Rename file
  113. def rename_file(old_path, new_path)
  114. return false unless valid_file_path?(old_path) && valid_file_path?(new_path)
  115. old_full_path = @theme_path.join(old_path)
  116. new_full_path = @theme_path.join(new_path)
  117. unless File.exist?(old_full_path)
  118. @errors << "File not found: #{old_path}"
  119. return false
  120. end
  121. if File.exist?(new_full_path)
  122. @errors << "File already exists: #{new_path}"
  123. return false
  124. end
  125. FileUtils.mv(old_full_path, new_full_path)
  126. true
  127. rescue => e
  128. @errors << "Failed to rename file: #{e.message}"
  129. false
  130. end
  131. # Search in files
  132. def search(query)
  133. return [] if query.blank?
  134. results = []
  135. Dir.glob(@theme_path.join('**', '*')).each do |file_path|
  136. next unless File.file?(file_path)
  137. next unless editable_file?(file_path)
  138. begin
  139. content = File.read(file_path)
  140. relative_path = Pathname.new(file_path).relative_path_from(@theme_path).to_s
  141. content.each_line.with_index do |line, line_number|
  142. if line.include?(query)
  143. results << {
  144. file: relative_path,
  145. line: line_number + 1,
  146. content: line.strip,
  147. match: line.index(query)
  148. }
  149. end
  150. end
  151. rescue => e
  152. # Skip files that can't be read
  153. end
  154. end
  155. results
  156. end
  157. # Get file versions
  158. def file_versions(file_path)
  159. ThemeFileVersion.where(
  160. theme_name: @theme_name,
  161. file_path: file_path
  162. ).order(created_at: :desc).limit(20)
  163. end
  164. # Restore file from version
  165. def restore_version(version_id)
  166. version = ThemeFileVersion.find(version_id)
  167. return false unless version.theme_name == @theme_name
  168. write_file(version.file_path, version.content)
  169. end
  170. private
  171. def validate_theme_exists!
  172. unless File.directory?(@theme_path)
  173. raise ArgumentError, "Theme not found: #{@theme_name}"
  174. end
  175. end
  176. def valid_directory?(directory)
  177. # Prevent directory traversal
  178. return false if directory.include?('..')
  179. return false if directory.start_with?('/')
  180. true
  181. end
  182. def valid_file_path?(file_path)
  183. # Prevent path traversal attacks
  184. return false if file_path.include?('..')
  185. return false if file_path.start_with?('/')
  186. # Must be within theme directory
  187. full_path = @theme_path.join(file_path)
  188. return false unless full_path.to_s.start_with?(@theme_path.to_s)
  189. true
  190. end
  191. def editable_file?(file_path)
  192. ext = File.extname(file_path).downcase
  193. EDITABLE_EXTENSIONS.include?(ext)
  194. end
  195. def build_tree(directory, prefix = '')
  196. entries = []
  197. Dir.entries(directory).sort.each do |entry|
  198. next if entry.start_with?('.')
  199. entry_path = File.join(directory, entry)
  200. relative_path = Pathname.new(entry_path).relative_path_from(@theme_path).to_s
  201. if File.directory?(entry_path)
  202. entries << {
  203. name: entry,
  204. path: relative_path,
  205. type: 'directory',
  206. children: build_tree(entry_path, "#{prefix}#{entry}/")
  207. }
  208. else
  209. entries << {
  210. name: entry,
  211. path: relative_path,
  212. type: 'file',
  213. editable: editable_file?(entry),
  214. extension: File.extname(entry),
  215. size: File.size(entry_path)
  216. }
  217. end
  218. end
  219. entries
  220. end
  221. def create_backup(file_path)
  222. backup_dir = Rails.root.join('tmp', 'theme_backups', @theme_name)
  223. FileUtils.mkdir_p(backup_dir)
  224. timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
  225. backup_file = backup_dir.join("#{File.basename(file_path)}.#{timestamp}.bak")
  226. FileUtils.cp(file_path, backup_file)
  227. end
  228. def create_version_record(file_path, content)
  229. ThemeFileVersion.create!(
  230. theme_name: @theme_name,
  231. file_path: file_path,
  232. content: content,
  233. file_size: content.bytesize,
  234. user_id: nil # TODO: Pass user from controller
  235. )
  236. rescue => e
  237. Rails.logger.error "Failed to create version record: #{e.message}"
  238. end
  239. end

app/services/theme_preview_renderer.rb

0.0% lines covered

100.0% branches covered

137 relevant lines. 0 lines covered and 137 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemePreviewRenderer
  2. attr_reader :published_version, :builder_renderer
  3. def initialize(builder_theme, template_name = 'index')
  4. @builder_theme = builder_theme
  5. @template_name = template_name
  6. @theme_preview = ThemePreview.find_or_create_for_builder(builder_theme, template_name)
  7. # Use published version for base files (layout, assets)
  8. @published_version = builder_theme.published_version
  9. # Create a mock BuilderTheme for the existing BuilderLiquidRenderer
  10. @mock_builder_theme = create_mock_builder_theme
  11. Rails.logger.info "Created mock builder theme for ThemePreviewRenderer"
  12. @builder_renderer = BuilderLiquidRenderer.new(@mock_builder_theme)
  13. Rails.logger.info "Created BuilderLiquidRenderer for preview"
  14. end
  15. # Render a template with all sections, header, footer, etc.
  16. def render
  17. # Use the existing BuilderLiquidRenderer
  18. html = @builder_renderer.render_template(@template_name)
  19. # Replace asset URLs with embedded content for preview
  20. html = replace_asset_urls_with_content(html)
  21. html
  22. rescue => e
  23. Rails.logger.error "ThemePreviewRenderer error: #{e.message}"
  24. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  25. "<div class='error'>ThemePreviewRenderer Error: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
  26. end
  27. # Get CSS and JS assets including all sections
  28. def assets
  29. # Use the existing BuilderLiquidRenderer's assets method
  30. @builder_renderer.assets
  31. end
  32. private
  33. def create_mock_builder_theme
  34. # Create a mock BuilderTheme object that delegates to ThemePreview data
  35. mock_theme = Object.new
  36. # Define methods that BuilderLiquidRenderer expects
  37. def mock_theme.get_rendered_file(template_name)
  38. # Return the template data from ThemePreview (not PublishedThemeFile)
  39. template_content = @theme_preview.template_content
  40. # Get layout file from PublishedThemeFile (base files)
  41. layout_file = @published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
  42. layout_content = layout_file&.content || default_layout
  43. # Build page sections from ThemePreview data
  44. page_sections = []
  45. template_content['order']&.each_with_index do |section_id, index|
  46. section_config = template_content['sections'][section_id]
  47. next unless section_config
  48. # Create a mock section object with blocks support
  49. section = Object.new
  50. def section.section_id
  51. @section_id
  52. end
  53. def section.section_type
  54. @section_type
  55. end
  56. def section.settings
  57. @settings
  58. end
  59. def section.position
  60. @position
  61. end
  62. def section.blocks
  63. @blocks || []
  64. end
  65. section.instance_variable_set(:@section_id, section_id)
  66. section.instance_variable_set(:@section_type, section_config['type'])
  67. section.instance_variable_set(:@settings, section_config['settings'] || {})
  68. section.instance_variable_set(:@position, index)
  69. # Add blocks support if the section has blocks
  70. if section_config['blocks']
  71. blocks = section_config['blocks'].map do |block_data|
  72. block = Object.new
  73. def block.id
  74. @id
  75. end
  76. def block.type
  77. @type
  78. end
  79. def block.settings
  80. @settings
  81. end
  82. block.instance_variable_set(:@id, block_data['id'] || SecureRandom.hex(8))
  83. block.instance_variable_set(:@type, block_data['type'])
  84. block.instance_variable_set(:@settings, block_data['settings'] || {})
  85. block
  86. end
  87. section.instance_variable_set(:@blocks, blocks)
  88. end
  89. page_sections << section
  90. end
  91. {
  92. template_name: template_name,
  93. template_content: template_content,
  94. layout_content: layout_content,
  95. theme_settings: {},
  96. page_sections: page_sections
  97. }
  98. end
  99. # Store the theme_preview, published_version, and builder_theme for access in methods
  100. mock_theme.instance_variable_set(:@theme_preview, @theme_preview)
  101. mock_theme.instance_variable_set(:@published_version, @published_version)
  102. mock_theme.instance_variable_set(:@builder_theme, @builder_theme)
  103. # Add other methods that might be needed
  104. def mock_theme.theme_name
  105. @published_version.theme.name.underscore
  106. end
  107. def mock_theme.id
  108. @builder_theme.id
  109. end
  110. mock_theme
  111. end
  112. def default_layout
  113. <<~HTML
  114. <!DOCTYPE html>
  115. <html lang="en">
  116. <head>
  117. <meta charset="UTF-8">
  118. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  119. <title>{{ page.title | default: site.title }}</title>
  120. </head>
  121. <body>
  122. {{ content_for_layout }}
  123. </body>
  124. </html>
  125. HTML
  126. end
  127. def replace_asset_urls_with_content(html)
  128. # Get assets from published theme files
  129. published_version = @builder_theme.published_version
  130. # Get CSS content
  131. css_file = published_version.published_theme_files.find_by(file_path: 'assets/theme.css')
  132. css_content = css_file&.content || ''
  133. # Get JS content
  134. js_file = published_version.published_theme_files.find_by(file_path: 'assets/theme.js')
  135. js_content = js_file&.content || ''
  136. # Replace CSS link tags with embedded styles
  137. html = html.gsub(/<link[^>]*href="[^"]*\/theme\.css"[^>]*>/) do |match|
  138. if css_content.present?
  139. "<style>#{css_content}</style>"
  140. else
  141. match # Keep original if no CSS
  142. end
  143. end
  144. # Replace JS script tags with embedded scripts
  145. html = html.gsub(/<script[^>]*src="[^"]*\/theme\.js"[^>]*><\/script>/) do |match|
  146. if js_content.present?
  147. "<script>#{js_content}</script>"
  148. else
  149. match # Keep original if no JS
  150. end
  151. end
  152. html
  153. rescue => e
  154. Rails.logger.error "Error in replace_asset_urls_with_content: #{e.message}"
  155. Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
  156. html # Return original HTML if there's an error
  157. end
  158. end

app/services/theme_version_loader.rb

0.0% lines covered

100.0% branches covered

122 relevant lines. 0 lines covered and 122 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeVersionLoader
  2. class << self
  3. def current_theme_version
  4. @current_theme_version ||= ThemeVersion.live.for_theme(current_theme_name).first
  5. end
  6. def current_theme_name
  7. @current_theme_name ||= Railspress::ThemeLoader.current_theme
  8. end
  9. def load_template(template_type)
  10. if current_theme_version
  11. current_theme_version.template_data(template_type)
  12. else
  13. load_base_template(template_type)
  14. end
  15. end
  16. def load_section(section_type)
  17. if current_theme_version
  18. current_theme_version.section_content(section_type)
  19. else
  20. load_base_section(section_type)
  21. end
  22. end
  23. def load_layout
  24. if current_theme_version
  25. current_theme_version.layout_content
  26. else
  27. load_base_layout
  28. end
  29. end
  30. def load_assets
  31. if current_theme_version
  32. current_theme_version.assets
  33. else
  34. load_base_assets
  35. end
  36. end
  37. def render_template(template_type, context = {})
  38. template_data = load_template(template_type)
  39. layout_content = load_layout
  40. # Render sections from template data
  41. sections_html = render_sections_from_template_data(template_data, context)
  42. # Combine with layout
  43. layout_content.gsub('{{ content_for_layout }}', sections_html)
  44. end
  45. private
  46. def render_sections_from_template_data(template_data, context)
  47. return '' unless template_data['order'] && template_data['sections']
  48. sections_html = ''
  49. template_data['order'].each do |section_id|
  50. section_data = template_data['sections'][section_id]
  51. next unless section_data
  52. section_content = load_section(section_data['type'])
  53. next unless section_content
  54. # Create liquid template
  55. template = Liquid::Template.parse(section_content)
  56. # Prepare context
  57. liquid_context = {
  58. 'section' => {
  59. 'settings' => section_data['settings'] || {},
  60. 'id' => section_id,
  61. 'type' => section_data['type']
  62. }
  63. }.merge(context)
  64. # Render section
  65. sections_html += template.render(liquid_context)
  66. end
  67. sections_html
  68. rescue => e
  69. Rails.logger.error "Error rendering template: #{e.message}"
  70. ''
  71. end
  72. def load_base_template(template_type)
  73. theme_path = Rails.root.join('app', 'themes', current_theme_name)
  74. template_file = theme_path.join('templates', "#{template_type}.json")
  75. if File.exist?(template_file)
  76. JSON.parse(File.read(template_file))
  77. else
  78. { 'sections' => {}, 'order' => [] }
  79. end
  80. end
  81. def load_base_section(section_type)
  82. theme_path = Rails.root.join('app', 'themes', current_theme_name)
  83. section_file = theme_path.join('sections', "#{section_type}.liquid")
  84. File.exist?(section_file) ? File.read(section_file) : ''
  85. end
  86. def load_base_layout
  87. theme_path = Rails.root.join('app', 'themes', current_theme_name)
  88. layout_file = theme_path.join('layout', 'theme.liquid')
  89. if File.exist?(layout_file)
  90. File.read(layout_file)
  91. else
  92. default_layout
  93. end
  94. end
  95. def load_base_assets
  96. theme_path = Rails.root.join('app', 'themes', current_theme_name)
  97. {
  98. css: load_asset_file(theme_path, 'assets/theme.css'),
  99. js: load_asset_file(theme_path, 'assets/theme.js')
  100. }
  101. end
  102. def load_asset_file(theme_path, asset_path)
  103. asset_file = theme_path.join(asset_path)
  104. File.exist?(asset_file) ? File.read(asset_file) : ''
  105. end
  106. def default_layout
  107. <<~LIQUID
  108. <!DOCTYPE html>
  109. <html>
  110. <head>
  111. <title>{{ page.title }}</title>
  112. <meta name="description" content="{{ page.description }}">
  113. <style>
  114. {{ assets.css }}
  115. </style>
  116. </head>
  117. <body>
  118. {{ content_for_layout }}
  119. <script>
  120. {{ assets.js }}
  121. </script>
  122. </body>
  123. </html>
  124. LIQUID
  125. end
  126. end
  127. end

app/services/theme_version_service.rb

0.0% lines covered

100.0% branches covered

87 relevant lines. 0 lines covered and 87 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemeVersionService
  2. def initialize(theme_version)
  3. @theme_version = theme_version
  4. @theme_name = theme_version.theme_name
  5. @theme_path = Rails.root.join('app', 'themes', @theme_name)
  6. end
  7. def snapshot_theme_files
  8. return false unless Dir.exist?(@theme_path)
  9. # Create file versions for all theme files
  10. snapshot_directory_recursive(@theme_path.to_s, '')
  11. true
  12. rescue => e
  13. Rails.logger.error "Error snapshotting theme files: #{e.message}"
  14. false
  15. end
  16. def update_file(file_path, content)
  17. # Create or find the theme file
  18. theme_file = ThemeFile.find_or_create_from_path(@theme_name, file_path)
  19. # Create a new version linked to this theme version
  20. ThemeFileVersion.create_version(@theme_name, file_path, content, @theme_version.user, @theme_version)
  21. end
  22. def update_template(template_type, template_data)
  23. file_path = "templates/#{template_type}.json"
  24. content = JSON.pretty_generate(template_data)
  25. update_file(file_path, content)
  26. end
  27. def update_section(section_type, content)
  28. file_path = "sections/#{section_type}.liquid"
  29. update_file(file_path, content)
  30. end
  31. def update_layout(content)
  32. file_path = "layout/theme.liquid"
  33. update_file(file_path, content)
  34. end
  35. def update_asset(asset_type, content)
  36. file_path = "assets/#{asset_type}"
  37. update_file(file_path, content)
  38. end
  39. def get_file_content(file_path)
  40. @theme_version.theme_file_versions.find_by(file_path: file_path)&.content
  41. end
  42. def get_template_data(template_type)
  43. content = get_file_content("templates/#{template_type}.json")
  44. content ? JSON.parse(content) : {}
  45. rescue JSON::ParserError
  46. {}
  47. end
  48. def get_section_content(section_type)
  49. get_file_content("sections/#{section_type}.liquid") || ''
  50. end
  51. def get_layout_content
  52. get_file_content("layout/theme.liquid") || ''
  53. end
  54. def get_assets
  55. {
  56. css: get_file_content("assets/theme.css") || '',
  57. js: get_file_content("assets/theme.js") || ''
  58. }
  59. end
  60. def render_preview(template_type)
  61. renderer = LiquidTemplateVersionRenderer.new(@theme_version, template_type)
  62. renderer.render
  63. end
  64. private
  65. def snapshot_directory_recursive(source_dir, relative_path)
  66. Dir.entries(source_dir).each do |entry|
  67. next if entry.start_with?('.')
  68. next if entry == 'node_modules'
  69. source_path = File.join(source_dir, entry)
  70. relative_file_path = relative_path.blank? ? entry : File.join(relative_path, entry)
  71. if File.directory?(source_path)
  72. snapshot_directory_recursive(source_path, relative_file_path)
  73. else
  74. snapshot_file(source_path, relative_file_path)
  75. end
  76. end
  77. end
  78. def snapshot_file(source_path, relative_path)
  79. return unless should_snapshot_file?(relative_path)
  80. content = File.read(source_path)
  81. # Create or find the theme file
  82. theme_file = ThemeFile.find_or_create_from_path(@theme_name, relative_path)
  83. # Create a new version linked to this theme version
  84. ThemeFileVersion.create_version(@theme_name, relative_path, content, @theme_version.user, @theme_version)
  85. rescue => e
  86. Rails.logger.error "Error snapshotting file #{relative_path}: #{e.message}"
  87. end
  88. def should_snapshot_file?(file_path)
  89. # Only snapshot theme-related files
  90. extensions = %w[.liquid .json .css .js .yml .yaml .md .txt]
  91. extensions.any? { |ext| file_path.end_with?(ext) }
  92. end
  93. end

app/services/themes_manager.rb

0.0% lines covered

100.0% branches covered

530 relevant lines. 0 lines covered and 530 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ThemesManager
  2. include ActiveModel::Model
  3. attr_accessor :themes_path
  4. def initialize
  5. @themes_path = Rails.root.join('app', 'themes')
  6. end
  7. # Get all themes from filesystem
  8. def scan_themes
  9. themes = []
  10. return themes unless Dir.exist?(@themes_path)
  11. Dir.glob(File.join(@themes_path, '*')).each do |theme_dir|
  12. next unless File.directory?(theme_dir)
  13. theme_name = File.basename(theme_dir)
  14. theme_json_file = File.join(theme_dir, 'config', 'theme.json')
  15. if File.exist?(theme_json_file)
  16. theme_data = JSON.parse(File.read(theme_json_file))
  17. # Handle both array and hash formats
  18. theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
  19. themes << {
  20. name: theme_info['name'] || theme_name,
  21. slug: theme_name.parameterize,
  22. description: theme_info['description'] || "Theme: #{theme_name}",
  23. version: theme_info['version'] || '1.0.0',
  24. config: theme_info
  25. }
  26. else
  27. themes << {
  28. name: theme_name,
  29. slug: theme_name.parameterize,
  30. description: "Theme: #{theme_name}",
  31. version: '1.0.0',
  32. config: {}
  33. }
  34. end
  35. end
  36. themes
  37. end
  38. # Sync a specific theme from filesystem to database
  39. def sync_theme(theme_slug)
  40. theme_dir = File.join(@themes_path, theme_slug)
  41. return false unless Dir.exist?(theme_dir)
  42. theme_json_file = File.join(theme_dir, 'config', 'theme.json')
  43. if File.exist?(theme_json_file)
  44. theme_data = JSON.parse(File.read(theme_json_file))
  45. # Handle both array and hash formats
  46. theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
  47. theme_config = {
  48. name: theme_info['name'] || theme_slug.titleize,
  49. slug: theme_slug,
  50. description: theme_info['description'] || "Theme: #{theme_slug.titleize}",
  51. version: theme_info['version'] || '1.0.0',
  52. config: theme_info
  53. }
  54. else
  55. theme_config = {
  56. name: theme_slug.titleize,
  57. slug: theme_slug,
  58. description: "Theme: #{theme_slug.titleize}",
  59. version: '1.0.0',
  60. config: {}
  61. }
  62. end
  63. # Find or create theme
  64. theme = Theme.find_or_create_by(name: theme_config[:name]) do |t|
  65. t.slug = theme_config[:slug]
  66. t.description = theme_config[:description]
  67. t.version = theme_config[:version]
  68. t.config = theme_config[:config]
  69. t.active = false
  70. # Use ActsAsTenant.current_tenant or fallback to first tenant
  71. t.tenant = ActsAsTenant.current_tenant || Tenant.first
  72. end
  73. # Update if changed
  74. theme.update!(
  75. slug: theme_config[:slug],
  76. description: theme_config[:description],
  77. version: theme_config[:version],
  78. config: theme_config[:config]
  79. )
  80. # Sync theme files for this theme
  81. sync_theme_files(theme)
  82. theme
  83. end
  84. # Sync themes from filesystem to database
  85. def sync_themes
  86. themes = scan_themes
  87. synced_count = 0
  88. themes.each do |theme_data|
  89. theme = Theme.find_or_create_by(slug: theme_data[:slug]) do |t|
  90. t.name = theme_data[:name]
  91. t.description = theme_data[:description]
  92. t.version = theme_data[:version]
  93. t.config = theme_data[:config]
  94. t.active = false
  95. # Use ActsAsTenant.current_tenant or fallback to first tenant
  96. # Ensure we get a proper Tenant model instance, not OpenStruct
  97. current_tenant = ActsAsTenant.current_tenant
  98. if current_tenant.is_a?(OpenStruct)
  99. t.tenant = Tenant.find(current_tenant.id)
  100. else
  101. t.tenant = current_tenant || Tenant.first
  102. end
  103. end
  104. # Update if changed
  105. if theme.changed?
  106. theme.update!(theme_data)
  107. synced_count += 1
  108. end
  109. # Create initial version if none exists
  110. create_initial_version_if_needed(theme)
  111. # Sync files and detect changes
  112. sync_theme_files(theme)
  113. end
  114. synced_count
  115. end
  116. # Create initial theme version if none exists
  117. def create_initial_version_if_needed(theme)
  118. return if ThemeVersion.for_theme(theme.name).exists?
  119. # Create initial version
  120. theme_version = ThemeVersion.create!(
  121. theme_name: theme.name,
  122. version: theme.version || '1.0.0',
  123. user: User.first,
  124. is_live: true,
  125. is_preview: false,
  126. published_at: Time.current,
  127. change_summary: "Initial version from filesystem"
  128. )
  129. # Create theme files for this version
  130. create_theme_files_for_version(theme_version)
  131. # If this is an active theme, also create PublishedThemeVersion
  132. if theme.active?
  133. theme.ensure_published_version_exists!
  134. end
  135. end
  136. # Create theme files for a specific version
  137. def create_theme_files_for_version(theme_version)
  138. # Use the theme's slug for the directory path, not the name
  139. theme = Theme.find_by(name: theme_version.theme_name)
  140. theme_slug = theme&.slug || theme_version.theme_name.parameterize
  141. theme_path = File.join(@themes_path, theme_slug)
  142. return unless Dir.exist?(theme_path)
  143. files = find_theme_files(theme_path)
  144. files.each do |file_path|
  145. relative_path = file_path.gsub("#{theme_path}/", '')
  146. content = File.read(file_path)
  147. file_checksum = Digest::SHA256.hexdigest(content)
  148. # Create theme file for this version - store FULL PATH
  149. theme_file = ThemeFile.find_or_create_by(
  150. theme_name: theme_version.theme_name,
  151. file_path: file_path, # Store full path, not relative
  152. theme_version_id: theme_version.id
  153. ) do |tf|
  154. tf.file_type = determine_file_type(relative_path)
  155. tf.current_checksum = file_checksum
  156. end
  157. # Update checksum if file exists but checksum differs
  158. if theme_file.persisted? && theme_file.current_checksum != file_checksum
  159. theme_file.update!(current_checksum: file_checksum)
  160. end
  161. # Create initial file version if none exists
  162. create_file_version_if_needed(theme_file, content, file_checksum)
  163. end
  164. end
  165. # Create file version if needed (checksum-based)
  166. def create_file_version_if_needed(theme_file, content, file_checksum)
  167. # Check if we already have a version with this checksum
  168. existing_version = theme_file.theme_file_versions.find_by(file_checksum: file_checksum)
  169. return existing_version if existing_version
  170. # Create new version
  171. version_number = (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1
  172. ThemeFileVersion.create!(
  173. theme_file: theme_file,
  174. content: content,
  175. file_size: content.bytesize,
  176. file_checksum: file_checksum,
  177. user: User.first,
  178. change_summary: "Synced from filesystem",
  179. version_number: version_number,
  180. theme_version_id: theme_file.theme_version_id
  181. )
  182. end
  183. # Sync theme files and detect changes
  184. def sync_theme_files(theme)
  185. theme_version = theme.theme_versions.live.first
  186. return unless theme_version
  187. # Use theme slug for directory path, not name
  188. theme_slug = theme.slug || theme.name.parameterize
  189. theme_path = File.join(@themes_path, theme_slug)
  190. return unless Dir.exist?(theme_path)
  191. files = find_theme_files(theme_path)
  192. files_processed = 0
  193. versions_created = 0
  194. published_files_updated = 0
  195. files.each do |file_path|
  196. relative_path = file_path.gsub("#{theme_path}/", '')
  197. content = File.read(file_path)
  198. file_checksum = Digest::SHA256.hexdigest(content)
  199. # Find or create theme file (use full path for consistency)
  200. theme_file = ThemeFile.find_or_create_by(
  201. theme_name: theme.name,
  202. file_path: file_path, # Store full path
  203. theme_version_id: theme_version.id
  204. ) do |tf|
  205. tf.file_type = determine_file_type(relative_path)
  206. tf.current_checksum = file_checksum
  207. end
  208. files_processed += 1
  209. # Check if file has changed (different checksum)
  210. if theme_file.current_checksum != file_checksum
  211. # Update checksum
  212. theme_file.update!(current_checksum: file_checksum)
  213. # Create new version
  214. version = create_file_version_if_needed(theme_file, content, file_checksum)
  215. versions_created += 1 if version
  216. # Update published files if theme is active
  217. if theme.active?
  218. updated = update_published_files_if_needed(theme, relative_path, content)
  219. published_files_updated += 1 if updated
  220. end
  221. end
  222. end
  223. { files_processed: files_processed, versions_created: versions_created, published_files_updated: published_files_updated }
  224. end
  225. # Get active theme
  226. def active_theme
  227. Theme.active.first
  228. end
  229. # Get active theme version for active theme
  230. def active_theme_version
  231. theme = active_theme
  232. return nil unless theme
  233. ThemeVersion.for_theme(theme.name).live.first
  234. end
  235. # Get file content for active theme or specific theme
  236. def get_file(file_path, theme_name = nil)
  237. if theme_name
  238. # Get file from specific theme
  239. theme_version = ThemeVersion.for_theme(theme_name).live.first
  240. else
  241. # Get file from active theme
  242. theme_version = active_theme_version
  243. end
  244. return nil unless theme_version
  245. # Build full path for lookup - use lowercase theme name for filesystem
  246. theme_path = File.join(@themes_path, (theme_name || active_theme.name).downcase)
  247. full_path = File.join(theme_path, file_path)
  248. # Try to find by full path
  249. theme_file = theme_version.theme_files.find_by(file_path: full_path)
  250. return theme_file.theme_file_versions.latest.first&.content if theme_file
  251. # If not found, try to find by matching the end of the path (for legacy data)
  252. theme_file = theme_version.theme_files.find { |file| file.file_path.end_with?("/#{file_path}") }
  253. return nil unless theme_file
  254. theme_file.theme_file_versions.latest.first&.content
  255. end
  256. # Get file content for builder theme (with overrides)
  257. def get_builder_file(builder_theme, file_path)
  258. # Check if builder has an override for this file
  259. builder_files = builder_theme.settings_data['builder_files'] || {}
  260. if builder_files[file_path]
  261. return builder_files[file_path]['content']
  262. end
  263. # Fall back to regular theme file
  264. get_file(file_path)
  265. end
  266. # Get parsed file content (for JSON files)
  267. def get_parsed_file(file_path)
  268. content = get_file(file_path)
  269. return nil unless content
  270. if file_path.end_with?('.json')
  271. JSON.parse(content)
  272. else
  273. content
  274. end
  275. rescue JSON::ParserError
  276. nil
  277. end
  278. # Create new file version (for Monaco editor saves)
  279. def create_file_version(theme_file, content, user = nil)
  280. file_checksum = Digest::SHA256.hexdigest(content)
  281. theme_version = theme_file.theme_version
  282. # Create new version
  283. version = ThemeFileVersion.create!(
  284. theme_file: theme_file,
  285. content: content,
  286. file_size: content.bytesize,
  287. file_checksum: file_checksum,
  288. user: user || User.first,
  289. change_summary: "Edited via Monaco Editor",
  290. version_number: (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1,
  291. theme_version_id: theme_version.id
  292. )
  293. # Update theme file checksum and current version
  294. theme_file.update!(
  295. current_checksum: file_checksum,
  296. current_version: version.version_number
  297. )
  298. version
  299. end
  300. # Get all files for a theme
  301. def theme_files(theme_name)
  302. theme_version = ThemeVersion.for_theme(theme_name).live.first
  303. return [] unless theme_version
  304. theme_version.theme_files
  305. end
  306. # Get file tree structure
  307. def file_tree(theme_name)
  308. files = theme_files(theme_name)
  309. tree_hash = build_file_tree(files)
  310. # Convert hash tree to array format expected by the view
  311. convert_tree_to_array(tree_hash)
  312. end
  313. # Check for theme updates
  314. def check_for_updates(theme)
  315. return false unless theme
  316. # Compare filesystem version with database version
  317. theme_path = File.join(@themes_path, theme.name)
  318. theme_json_file = File.join(theme_path, 'config', 'theme.json')
  319. if File.exist?(theme_json_file)
  320. theme_data = JSON.parse(File.read(theme_json_file))
  321. theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
  322. filesystem_version = theme_info['version'] || '1.0.0'
  323. database_version = theme.version || '1.0.0'
  324. filesystem_version != database_version
  325. else
  326. false
  327. end
  328. end
  329. # Update published theme files when files change
  330. def update_published_files_if_needed(theme, relative_path, content)
  331. published_version = theme.published_version
  332. return false unless published_version
  333. # Find or create published theme file
  334. published_file = published_version.published_theme_files.find_or_create_by(
  335. file_path: relative_path
  336. ) do |pf|
  337. pf.file_type = determine_file_type(relative_path)
  338. pf.content = content
  339. pf.checksum = Digest::MD5.hexdigest(content)
  340. end
  341. # Check if content has changed
  342. new_checksum = Digest::MD5.hexdigest(content)
  343. if published_file.checksum != new_checksum
  344. published_file.update!(
  345. content: content,
  346. checksum: new_checksum
  347. )
  348. Rails.logger.info "Updated published file: #{relative_path} for theme: #{theme.name}"
  349. return true
  350. end
  351. false
  352. rescue => e
  353. Rails.logger.error "Failed to update published file #{relative_path}: #{e.message}"
  354. false
  355. end
  356. private
  357. # Convert tree hash to array format for the view
  358. def convert_tree_to_array(tree_hash, path = '')
  359. result = []
  360. tree_hash.each do |name, content|
  361. current_path = path.empty? ? name : "#{path}/#{name}"
  362. if content.is_a?(Hash) && content[:type] == 'file'
  363. # This is a file
  364. result << {
  365. name: name,
  366. path: current_path,
  367. type: 'file',
  368. editable: content[:editable] || false,
  369. extension: File.extname(name),
  370. size: content[:size]
  371. }
  372. elsif content.is_a?(Hash) && content[:type] == 'directory'
  373. # This is a directory
  374. children = convert_tree_to_array(content[:children] || {}, current_path)
  375. result << {
  376. name: name,
  377. path: current_path,
  378. type: 'directory',
  379. children: children
  380. }
  381. elsif content.is_a?(Hash) && content[:children]
  382. # This is a directory (has children)
  383. children = convert_tree_to_array(content[:children], current_path)
  384. result << {
  385. name: name,
  386. path: current_path,
  387. type: 'directory',
  388. children: children
  389. }
  390. else
  391. # This might be a nested directory (no explicit type)
  392. children = convert_tree_to_array(content, current_path)
  393. if children.any? { |child| child[:type] == 'directory' }
  394. # Has subdirectories, treat as directory
  395. result << {
  396. name: name,
  397. path: current_path,
  398. type: 'directory',
  399. children: children
  400. }
  401. else
  402. # All files, add them directly
  403. result.concat(children)
  404. end
  405. end
  406. end
  407. result
  408. end
  409. def find_theme_files(theme_path)
  410. files = []
  411. Dir.glob(File.join(theme_path, '**', '*')).each do |file|
  412. next if File.directory?(file)
  413. files << file
  414. end
  415. files
  416. end
  417. def determine_file_type(file_path)
  418. if file_path.start_with?('templates/')
  419. 'template'
  420. elsif file_path.start_with?('sections/')
  421. 'section'
  422. elsif file_path.start_with?('layout/')
  423. 'layout'
  424. elsif file_path.start_with?('assets/')
  425. 'asset'
  426. elsif file_path.start_with?('config/')
  427. 'config'
  428. else
  429. 'other'
  430. end
  431. end
  432. def build_file_tree(files)
  433. tree = {}
  434. files.each do |file|
  435. # Extract the theme directory name from the absolute path
  436. # Path format: /path/to/app/themes/theme_name/...
  437. path_parts = file.file_path.split('/')
  438. theme_index = path_parts.index('themes')
  439. if theme_index && theme_index + 1 < path_parts.length
  440. # Get the relative path after the theme directory
  441. theme_dir = path_parts[theme_index + 1]
  442. relative_parts = path_parts[(theme_index + 2)..-1]
  443. relative_path = relative_parts.join('/')
  444. path_parts = relative_path.split('/')
  445. current = tree
  446. path_parts.each_with_index do |part, index|
  447. if index == path_parts.length - 1
  448. # This is a file
  449. current[part] = {
  450. type: 'file',
  451. path: relative_path,
  452. theme_file: file,
  453. editable: editable_file?(relative_path)
  454. }
  455. else
  456. # This is a directory
  457. current[part] ||= {
  458. type: 'directory',
  459. children: {}
  460. }
  461. current = current[part][:children]
  462. end
  463. end
  464. end
  465. end
  466. tree
  467. end
  468. def editable_file?(file_path)
  469. editable_extensions = %w[.liquid .json .css .js .scss .html .erb]
  470. editable_extensions.any? { |ext| file_path.end_with?(ext) }
  471. end
  472. # Additional methods for ThemeEditorController compatibility
  473. def create_file(file_path, content = '')
  474. return false unless valid_file_path?(file_path)
  475. full_path = File.join(@themes_path, active_theme.name, file_path)
  476. # Create directory if it doesn't exist
  477. FileUtils.mkdir_p(File.dirname(full_path))
  478. File.write(full_path, content)
  479. # Create theme file and version
  480. theme_version = active_theme_version
  481. return false unless theme_version
  482. theme_file = ThemeFile.create!(
  483. theme_name: active_theme.name,
  484. file_path: file_path,
  485. file_type: determine_file_type(file_path),
  486. theme_version: theme_version,
  487. current_checksum: Digest::SHA256.hexdigest(content)
  488. )
  489. ThemeFileVersion.create!(
  490. theme_file: theme_file,
  491. content: content,
  492. file_size: content.bytesize,
  493. file_checksum: Digest::SHA256.hexdigest(content),
  494. user: User.first,
  495. change_summary: "File created",
  496. version_number: 1,
  497. theme_version: theme_version
  498. )
  499. true
  500. rescue => e
  501. Rails.logger.error "Failed to create file: #{e.message}"
  502. false
  503. end
  504. def delete_file(file_path)
  505. return false unless valid_file_path?(file_path)
  506. full_path = File.join(@themes_path, active_theme.name, file_path)
  507. if File.exist?(full_path)
  508. File.delete(full_path)
  509. # Remove theme file and versions
  510. theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: file_path)
  511. theme_file&.destroy
  512. true
  513. else
  514. false
  515. end
  516. rescue => e
  517. Rails.logger.error "Failed to delete file: #{e.message}"
  518. false
  519. end
  520. def rename_file(old_path, new_path)
  521. return false unless valid_file_path?(old_path) && valid_file_path?(new_path)
  522. old_full_path = File.join(@themes_path, active_theme.name, old_path)
  523. new_full_path = File.join(@themes_path, active_theme.name, new_path)
  524. if File.exist?(old_full_path)
  525. FileUtils.mkdir_p(File.dirname(new_full_path))
  526. File.rename(old_full_path, new_full_path)
  527. # Update theme file path
  528. theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: old_path)
  529. if theme_file
  530. theme_file.update!(file_path: new_path)
  531. end
  532. true
  533. else
  534. false
  535. end
  536. rescue => e
  537. Rails.logger.error "Failed to rename file: #{e.message}"
  538. false
  539. end
  540. def search(query)
  541. return [] if query.blank?
  542. results = []
  543. theme_path = File.join(@themes_path, active_theme.name)
  544. Dir.glob(File.join(theme_path, '**', '*')).each do |file_path|
  545. next unless File.file?(file_path)
  546. next unless editable_file?(File.basename(file_path))
  547. begin
  548. content = File.read(file_path)
  549. relative_path = file_path.gsub("#{theme_path}/", '')
  550. content.each_line.with_index do |line, line_number|
  551. if line.include?(query)
  552. results << {
  553. file: relative_path,
  554. line: line_number + 1,
  555. content: line.strip,
  556. match: line.index(query)
  557. }
  558. end
  559. end
  560. rescue => e
  561. # Skip files that can't be read
  562. end
  563. end
  564. results
  565. end
  566. def file_versions(file_path)
  567. theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: file_path)
  568. return [] unless theme_file
  569. theme_file.theme_file_versions.order(version_number: :desc)
  570. end
  571. def restore_version(version_id)
  572. version = ThemeFileVersion.find(version_id)
  573. # Write to filesystem
  574. theme_path = File.join(@themes_path, active_theme.name, version.theme_file.file_path)
  575. File.write(theme_path, version.content)
  576. # Create new version
  577. theme_file = version.theme_file
  578. new_version = ThemeFileVersion.create!(
  579. theme_file: theme_file,
  580. content: version.content,
  581. file_size: version.content.bytesize,
  582. file_checksum: Digest::SHA256.hexdigest(version.content),
  583. user: User.first,
  584. change_summary: "Restored from version #{version.version_number}",
  585. version_number: (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1,
  586. theme_version: theme_file.theme_version
  587. )
  588. # Update theme file checksum
  589. theme_file.update!(current_checksum: new_version.file_checksum)
  590. true
  591. rescue => e
  592. Rails.logger.error "Failed to restore version: #{e.message}"
  593. false
  594. end
  595. def read_file(file_path)
  596. get_file(file_path)
  597. end
  598. def errors
  599. @errors ||= []
  600. end
  601. private
  602. def valid_file_path?(file_path)
  603. # Prevent path traversal attacks
  604. return false if file_path.include?('..')
  605. return false if file_path.start_with?('/')
  606. true
  607. end
  608. end

app/workers/export_worker.rb

0.0% lines covered

100.0% branches covered

133 relevant lines. 0 lines covered and 133 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ExportWorker
  2. include Sidekiq::Worker
  3. sidekiq_options retry: 3, queue: :default
  4. def perform(export_job_id)
  5. export_job = ExportJob.find(export_job_id)
  6. export_job.update(status: 'processing', progress: 0)
  7. case export_job.export_type
  8. when 'wordpress'
  9. export_wordpress_xml(export_job)
  10. when 'json'
  11. export_json(export_job)
  12. when 'csv'
  13. export_csv(export_job)
  14. when 'sql'
  15. export_sql(export_job)
  16. else
  17. raise "Unknown export type: #{export_job.export_type}"
  18. end
  19. export_job.update(
  20. status: 'completed',
  21. progress: 100
  22. )
  23. rescue => e
  24. Rails.logger.error("Export job #{export_job_id} failed: #{e.message}")
  25. Rails.logger.error(e.backtrace.join("\n"))
  26. export_job.update(status: 'failed')
  27. end
  28. private
  29. def export_json(export_job)
  30. options = export_job.metadata
  31. data = {}
  32. total_items = 0
  33. exported = 0
  34. if options['include_posts']
  35. posts = Post.kept
  36. posts = posts.published_status if !options['include_drafts']
  37. data['posts'] = posts.map { |p| post_to_json(p) }
  38. total_items += posts.count
  39. end
  40. if options['include_pages']
  41. pages = Page.kept
  42. pages = pages.published_status if !options['include_drafts']
  43. data['pages'] = pages.map { |p| page_to_json(p) }
  44. total_items += pages.count
  45. end
  46. if options['include_users']
  47. data['users'] = User.all.map { |u| user_to_json(u) }
  48. total_items += User.count
  49. end
  50. if options['include_settings']
  51. data['settings'] = {
  52. general: Settings.general,
  53. writing: Settings.writing,
  54. reading: Settings.reading
  55. }
  56. end
  57. export_job.update(total_items: total_items)
  58. # Write to file
  59. file_path = Rails.root.join('tmp', "export_#{export_job.id}.json")
  60. File.write(file_path, options['prettify_json'] ? JSON.pretty_generate(data) : data.to_json)
  61. export_job.update(
  62. file_path: file_path.to_s,
  63. file_name: "railspress_export_#{Date.today}.json",
  64. content_type: 'application/json',
  65. exported_items: total_items
  66. )
  67. end
  68. def export_wordpress_xml(export_job)
  69. # Generate WordPress WXR format
  70. builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
  71. xml.rss('version' => '2.0',
  72. 'xmlns:excerpt' => 'http://wordpress.org/export/1.2/excerpt/',
  73. 'xmlns:content' => 'http://purl.org/rss/1.0/modules/content/',
  74. 'xmlns:wp' => 'http://wordpress.org/export/1.2/') do
  75. xml.channel do
  76. xml.title Settings.site_title
  77. xml.link request.base_url
  78. posts = Post.kept
  79. posts.each do |post|
  80. xml.item do
  81. xml.title post.title
  82. xml.link "#{request.base_url}/blog/#{post.slug}"
  83. xml['content'].encoded { xml.cdata post.content.to_s }
  84. xml['wp'].post_name post.slug
  85. xml['wp'].status post.published_status? ? 'publish' : 'draft'
  86. xml['wp'].post_type 'post'
  87. xml['wp'].post_date post.created_at.strftime('%Y-%m-%d %H:%M:%S')
  88. end
  89. end
  90. end
  91. end
  92. end
  93. file_path = Rails.root.join('tmp', "export_#{export_job.id}.xml")
  94. File.write(file_path, builder.to_xml)
  95. export_job.update(
  96. file_path: file_path.to_s,
  97. file_name: "wordpress_export_#{Date.today}.xml",
  98. content_type: 'application/xml',
  99. exported_items: Post.kept.count
  100. )
  101. end
  102. def post_to_json(post)
  103. {
  104. id: post.id,
  105. title: post.title,
  106. slug: post.slug,
  107. content: post.content.to_s,
  108. excerpt: post.excerpt,
  109. status: post.status,
  110. published_at: post.published_at,
  111. author: post.user&.email,
  112. categories: post.terms.joins(:taxonomy).where(taxonomies: { slug: 'category' }).pluck(:name),
  113. tags: post.terms.joins(:taxonomy).where(taxonomies: { slug: 'tag' }).pluck(:name)
  114. }
  115. end
  116. def page_to_json(page)
  117. {
  118. id: page.id,
  119. title: page.title,
  120. slug: page.slug,
  121. content: page.content.to_s,
  122. status: page.status,
  123. published_at: page.published_at
  124. }
  125. end
  126. def user_to_json(user)
  127. {
  128. id: user.id,
  129. email: user.email,
  130. name: user.name,
  131. role: user.role,
  132. created_at: user.created_at
  133. }
  134. end
  135. end

app/workers/import_worker.rb

0.0% lines covered

100.0% branches covered

158 relevant lines. 0 lines covered and 158 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ImportWorker
  2. include Sidekiq::Worker
  3. sidekiq_options retry: 3, queue: :default
  4. def perform(import_job_id)
  5. import_job = ImportJob.find(import_job_id)
  6. import_job.update(status: 'processing', progress: 0)
  7. case import_job.import_type
  8. when 'wordpress'
  9. import_wordpress_xml(import_job)
  10. when 'json'
  11. import_json(import_job)
  12. when 'csv_posts'
  13. import_csv_posts(import_job)
  14. when 'csv_pages'
  15. import_csv_pages(import_job)
  16. when 'csv_users'
  17. import_csv_users(import_job)
  18. else
  19. raise "Unknown import type: #{import_job.import_type}"
  20. end
  21. import_job.update(
  22. status: 'completed',
  23. progress: 100,
  24. completed_at: Time.current
  25. )
  26. rescue => e
  27. Rails.logger.error("Import job #{import_job_id} failed: #{e.message}")
  28. Rails.logger.error(e.backtrace.join("\n"))
  29. import_job.update(
  30. status: 'failed',
  31. error_log: "#{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
  32. )
  33. end
  34. private
  35. def import_wordpress_xml(import_job)
  36. require 'nokogiri'
  37. doc = File.open(import_job.file_path) { |f| Nokogiri::XML(f) }
  38. items = doc.xpath('//item')
  39. import_job.update(total_items: items.count)
  40. imported = 0
  41. failed = 0
  42. items.each_with_index do |item, index|
  43. begin
  44. post_type = item.xpath('wp:post_type').text
  45. case post_type
  46. when 'post'
  47. create_post_from_wordpress(item, import_job)
  48. when 'page'
  49. create_page_from_wordpress(item, import_job)
  50. end
  51. imported += 1
  52. rescue => e
  53. Rails.logger.error("Failed to import item #{index}: #{e.message}")
  54. failed += 1
  55. end
  56. # Update progress
  57. progress = ((index + 1).to_f / items.count * 100).to_i
  58. import_job.update(progress: progress, imported_items: imported, failed_items: failed)
  59. end
  60. end
  61. def import_json(import_job)
  62. data = JSON.parse(File.read(import_job.file_path))
  63. total_items = (data['posts']&.count || 0) + (data['pages']&.count || 0)
  64. import_job.update(total_items: total_items)
  65. imported = 0
  66. # Import posts
  67. data['posts']&.each do |post_data|
  68. Post.create!(
  69. title: post_data['title'],
  70. content: post_data['content'],
  71. slug: post_data['slug'],
  72. status: post_data['status'] || 'draft',
  73. user_id: import_job.user_id
  74. )
  75. imported += 1
  76. import_job.update(progress: (imported.to_f / total_items * 100).to_i, imported_items: imported)
  77. end
  78. # Import pages
  79. data['pages']&.each do |page_data|
  80. Page.create!(
  81. title: page_data['title'],
  82. content: page_data['content'],
  83. slug: page_data['slug'],
  84. status: page_data['status'] || 'draft'
  85. )
  86. imported += 1
  87. import_job.update(progress: (imported.to_f / total_items * 100).to_i, imported_items: imported)
  88. end
  89. end
  90. def import_csv_posts(import_job)
  91. require 'csv'
  92. csv_data = CSV.read(import_job.file_path, headers: true)
  93. import_job.update(total_items: csv_data.count)
  94. csv_data.each_with_index do |row, index|
  95. Post.create!(
  96. title: row['title'],
  97. content: row['content'],
  98. slug: row['slug'] || row['title'].parameterize,
  99. status: row['status'] || 'draft',
  100. user_id: import_job.user_id
  101. )
  102. import_job.update(
  103. progress: ((index + 1).to_f / csv_data.count * 100).to_i,
  104. imported_items: index + 1
  105. )
  106. end
  107. end
  108. def import_csv_pages(import_job)
  109. require 'csv'
  110. csv_data = CSV.read(import_job.file_path, headers: true)
  111. import_job.update(total_items: csv_data.count)
  112. csv_data.each_with_index do |row, index|
  113. Page.create!(
  114. title: row['title'],
  115. content: row['content'],
  116. slug: row['slug'] || row['title'].parameterize,
  117. status: row['status'] || 'draft'
  118. )
  119. import_job.update(
  120. progress: ((index + 1).to_f / csv_data.count * 100).to_i,
  121. imported_items: index + 1
  122. )
  123. end
  124. end
  125. def import_csv_users(import_job)
  126. require 'csv'
  127. csv_data = CSV.read(import_job.file_path, headers: true)
  128. import_job.update(total_items: csv_data.count)
  129. csv_data.each_with_index do |row, index|
  130. User.create!(
  131. email: row['email'],
  132. name: row['name'],
  133. role: row['role'] || 'subscriber',
  134. password: SecureRandom.hex(16)
  135. )
  136. import_job.update(
  137. progress: ((index + 1).to_f / csv_data.count * 100).to_i,
  138. imported_items: index + 1
  139. )
  140. end
  141. end
  142. def create_post_from_wordpress(item, import_job)
  143. Post.create!(
  144. title: item.xpath('title').text,
  145. content: item.xpath('content:encoded').text,
  146. slug: item.xpath('wp:post_name').text,
  147. status: item.xpath('wp:status').text == 'publish' ? 'published' : 'draft',
  148. published_at: item.xpath('wp:post_date').text,
  149. user_id: import_job.user_id
  150. )
  151. end
  152. def create_page_from_wordpress(item, import_job)
  153. Page.create!(
  154. title: item.xpath('title').text,
  155. content: item.xpath('content:encoded').text,
  156. slug: item.xpath('wp:post_name').text,
  157. status: item.xpath('wp:status').text == 'publish' ? 'published' : 'draft',
  158. published_at: item.xpath('wp:post_date').text
  159. )
  160. end
  161. end

app/workers/personal_data_erasure_worker.rb

0.0% lines covered

100.0% branches covered

106 relevant lines. 0 lines covered and 106 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PersonalDataErasureWorker
  2. include Sidekiq::Worker
  3. sidekiq_options retry: 1, queue: :default
  4. def perform(request_id)
  5. request = PersonalDataErasureRequest.find(request_id)
  6. request.update(status: 'processing')
  7. user = User.find(request.user_id)
  8. begin
  9. # Create a backup before erasure (for audit purposes)
  10. create_erasure_backup(request, user)
  11. # Anonymize or delete personal data
  12. erase_user_data(user)
  13. # Update request status
  14. request.update!(
  15. status: 'completed',
  16. completed_at: Time.current,
  17. metadata: request.metadata.merge(
  18. erasure_completed_at: Time.current,
  19. erased_data_categories: get_erased_data_categories(user)
  20. )
  21. )
  22. # Log the completion
  23. Rails.logger.info("Personal data erasure completed for user #{user.email} (ID: #{user.id})")
  24. rescue => e
  25. Rails.logger.error("Personal data erasure failed for request #{request_id}: #{e.message}")
  26. request.update!(status: 'failed')
  27. raise e
  28. end
  29. end
  30. private
  31. def create_erasure_backup(request, user)
  32. # Create a minimal backup for audit purposes
  33. backup_data = {
  34. erasure_request_id: request.id,
  35. user_id: user.id,
  36. user_email: user.email,
  37. erasure_date: Time.current,
  38. reason: request.reason,
  39. metadata: request.metadata,
  40. data_categories_erased: get_data_categories_to_erase(user)
  41. }
  42. backup_file_path = Rails.root.join('tmp', "erasure_backup_#{request.id}.json")
  43. File.write(backup_file_path, JSON.pretty_generate(backup_data))
  44. # Store backup path in request metadata
  45. request.update!(
  46. metadata: request.metadata.merge(
  47. backup_file_path: backup_file_path.to_s
  48. )
  49. )
  50. end
  51. def erase_user_data(user)
  52. # 1. Anonymize user profile (keep account for system integrity)
  53. user.update!(
  54. email: "deleted_user_#{user.id}@deleted.local",
  55. name: "Deleted User",
  56. bio: nil,
  57. website: nil,
  58. phone: nil,
  59. location: nil,
  60. # Keep role and created_at for audit purposes
  61. # Keep tenant_id for system integrity
  62. )
  63. # 2. Delete user's posts (or anonymize if needed for system integrity)
  64. user.posts.each do |post|
  65. post.update!(
  66. title: "[Deleted Post]",
  67. content: "This post has been deleted due to data erasure request.",
  68. slug: "deleted-post-#{post.id}"
  69. )
  70. end
  71. # 3. Delete user's pages (or anonymize)
  72. user.pages.each do |page|
  73. page.update!(
  74. title: "[Deleted Page]",
  75. content: "This page has been deleted due to data erasure request.",
  76. slug: "deleted-page-#{page.id}"
  77. )
  78. end
  79. # 4. Delete user's media files
  80. user.media.each do |medium|
  81. # Delete the actual file
  82. medium.file.purge if medium.file.attached?
  83. # Delete the record
  84. medium.destroy!
  85. end
  86. # 5. Anonymize comments by email
  87. Comment.where(author_email: user.email).each do |comment|
  88. comment.update!(
  89. author_name: "Deleted User",
  90. author_email: "deleted@deleted.local",
  91. content: "[This comment has been deleted due to data erasure request.]"
  92. )
  93. end
  94. # 6. Delete subscriber records
  95. Subscriber.where(email: user.email).destroy_all
  96. # 7. Delete API tokens
  97. user.api_tokens.destroy_all
  98. # 8. Delete meta fields
  99. user.meta_fields.destroy_all
  100. # 9. Delete analytics data (pageviews)
  101. Pageview.where(user_id: user.id).destroy_all
  102. # 10. Delete consent records
  103. UserConsent.where(user: user).destroy_all
  104. # 11. Delete OAuth accounts
  105. user.oauth_accounts.destroy_all
  106. # 12. Delete AI usage records
  107. user.ai_usages.destroy_all
  108. # Note: We don't delete the user record itself to maintain referential integrity
  109. # The user account is anonymized but kept for audit purposes
  110. end
  111. def get_data_categories_to_erase(user)
  112. categories = []
  113. categories << 'profile_data' if user.persisted?
  114. categories << 'posts' if user.posts.exists?
  115. categories << 'pages' if user.pages.exists?
  116. categories << 'media' if user.media.exists?
  117. categories << 'comments' if Comment.where(author_email: user.email).exists?
  118. categories << 'subscribers' if Subscriber.where(email: user.email).exists?
  119. categories << 'api_tokens' if user.api_tokens.exists?
  120. categories << 'meta_fields' if user.meta_fields.exists?
  121. categories << 'analytics' if Pageview.where(user_id: user.id).exists?
  122. categories << 'consent_records' if UserConsent.where(user: user).exists?
  123. categories << 'oauth_accounts' if user.oauth_accounts.exists?
  124. categories << 'ai_usage' if user.ai_usages.exists?
  125. categories
  126. end
  127. def get_erased_data_categories(user)
  128. get_data_categories_to_erase(user)
  129. end
  130. end

app/workers/personal_data_export_worker.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PersonalDataExportWorker
  2. include Sidekiq::Worker
  3. sidekiq_options retry: 2, queue: :default
  4. def perform(request_id)
  5. request = PersonalDataExportRequest.find(request_id)
  6. request.update(status: 'processing')
  7. user = User.find(request.user_id)
  8. # Compile all personal data
  9. personal_data = {
  10. request_info: {
  11. requested_at: request.created_at,
  12. email: request.email,
  13. export_date: Time.current
  14. },
  15. user_profile: {
  16. id: user.id,
  17. email: user.email,
  18. name: user.name,
  19. role: user.role,
  20. bio: user.bio,
  21. website: user.website,
  22. created_at: user.created_at,
  23. updated_at: user.updated_at
  24. },
  25. posts: user.posts.map { |p|
  26. {
  27. title: p.title,
  28. slug: p.slug,
  29. content: p.content.to_s,
  30. status: p.status,
  31. published_at: p.published_at,
  32. created_at: p.created_at
  33. }
  34. },
  35. comments: Comment.where(author_email: user.email).map { |c|
  36. {
  37. content: c.content,
  38. author_name: c.author_name,
  39. post_title: c.commentable&.title,
  40. created_at: c.created_at,
  41. ip_address: c.ip_address
  42. }
  43. },
  44. subscribers: Subscriber.where(email: user.email).map { |s|
  45. {
  46. email: s.email,
  47. status: s.status,
  48. subscribed_at: s.confirmed_at,
  49. lists: s.lists
  50. }
  51. },
  52. pageviews: Pageview.where(user_id: user.id).group(:path).count,
  53. metadata: {
  54. total_posts: user.posts.count,
  55. total_comments: Comment.where(author_email: user.email).count,
  56. total_pageviews: Pageview.where(user_id: user.id).count
  57. }
  58. }
  59. # Write to file
  60. file_path = Rails.root.join('tmp', "personal_data_#{request.id}.json")
  61. File.write(file_path, JSON.pretty_generate(personal_data))
  62. request.update(
  63. status: 'completed',
  64. file_path: file_path.to_s,
  65. completed_at: Time.current
  66. )
  67. # Send notification email (optional)
  68. # PersonalDataMailer.export_ready(request).deliver_later
  69. rescue => e
  70. Rails.logger.error("Personal data export #{request_id} failed: #{e.message}")
  71. request.update(status: 'failed')
  72. end
  73. end

app/workers/post_by_email_worker.rb

0.0% lines covered

100.0% branches covered

13 relevant lines. 0 lines covered and 13 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PostByEmailWorker
  2. include Sidekiq::Worker
  3. sidekiq_options queue: :default, retry: 3
  4. def perform
  5. Rails.logger.info "Starting Post by Email check..."
  6. result = PostByEmailService.check_mail
  7. Rails.logger.info "Post by Email check completed: #{result[:new_posts]} new post(s), #{result[:checked]} email(s) checked"
  8. rescue => e
  9. Rails.logger.error "Post by Email worker failed: #{e.message}"
  10. Rails.logger.error e.backtrace.join("\n")
  11. raise e # Re-raise to trigger Sidekiq retry
  12. end
  13. end

lib/development_plugin_watcher.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class DevelopmentPluginWatcher
  2. def self.start_watching
  3. return unless Rails.env.development?
  4. # Create a thread to watch for plugin changes
  5. Thread.new do
  6. watch_plugin_changes
  7. end
  8. end
  9. private
  10. def self.watch_plugin_changes
  11. trigger_file = Rails.root.join('tmp', 'plugin_reload_trigger')
  12. loop do
  13. if File.exist?(trigger_file)
  14. begin
  15. data = JSON.parse(File.read(trigger_file))
  16. plugin_name = data['plugin_name']
  17. action = data['action']
  18. Rails.logger.info "🔄 Detected plugin #{action}: #{plugin_name}"
  19. # Remove the trigger file
  20. File.delete(trigger_file)
  21. # Trigger a graceful restart
  22. trigger_graceful_restart(plugin_name, action)
  23. rescue => e
  24. Rails.logger.error "Error processing plugin reload trigger: #{e.message}"
  25. File.delete(trigger_file) if File.exist?(trigger_file)
  26. end
  27. end
  28. sleep 1
  29. end
  30. end
  31. def self.trigger_graceful_restart(plugin_name, action)
  32. Rails.logger.info "🔄 Gracefully restarting server for plugin #{action}: #{plugin_name}"
  33. # Send a signal to restart the server
  34. # This is a development-only feature
  35. if defined?(Puma)
  36. # Puma restart
  37. Process.kill('USR1', Process.pid)
  38. elsif defined?(Unicorn)
  39. # Unicorn restart
  40. Process.kill('USR2', Process.pid)
  41. else
  42. # Fallback: create a restart file
  43. restart_file = Rails.root.join('tmp', 'restart.txt')
  44. FileUtils.touch(restart_file)
  45. Rails.logger.info "📝 Created restart file. Please restart the server manually."
  46. end
  47. end
  48. end

lib/generators/plugin_generator.rb

0.0% lines covered

100.0% branches covered

635 relevant lines. 0 lines covered and 635 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Plugin Generator for RailsPress
  2. #
  3. # Usage:
  4. # rails generate plugin MyPlugin
  5. # rails generate plugin MyPlugin --with-models
  6. # rails generate plugin MyPlugin --with-admin-ui
  7. # rails generate plugin MyPlugin --full
  8. #
  9. # This will create:
  10. # - Plugin class in lib/plugins/my_plugin/my_plugin.rb
  11. # - Model in app/models/ (if --with-models)
  12. # - Admin controller in app/controllers/admin/my_plugin/
  13. # - Frontend controller in app/controllers/plugins/my_plugin/
  14. # - Admin views in app/views/admin/my_plugin/
  15. # - Frontend views in app/views/plugins/my_plugin/
  16. # - Migration files
  17. # - Asset files
  18. class PluginGenerator < Rails::Generators::NamedBase
  19. source_root File.expand_path('templates', __dir__)
  20. class_option :with_models, type: :boolean, default: false,
  21. desc: 'Generate ActiveRecord models'
  22. class_option :with_admin_ui, type: :boolean, default: true,
  23. desc: 'Generate admin UI (controllers and views)'
  24. class_option :with_frontend, type: :boolean, default: false,
  25. desc: 'Generate frontend UI (controllers and views)'
  26. class_option :full, type: :boolean, default: false,
  27. desc: 'Generate everything (models, admin, frontend, assets)'
  28. class_option :author, type: :string, default: 'RailsPress',
  29. desc: 'Plugin author name'
  30. class_option :description, type: :string,
  31. desc: 'Plugin description'
  32. def create_plugin_structure
  33. @plugin_name = name
  34. @plugin_class = name.camelize
  35. @plugin_underscore = name.underscore
  36. @plugin_identifier = @plugin_underscore
  37. @author = options[:author]
  38. @description = options[:description] || "A RailsPress plugin"
  39. @full = options[:full]
  40. create_plugin_class
  41. create_models if options[:with_models] || @full
  42. create_controllers if options[:with_admin_ui] || options[:with_frontend] || @full
  43. create_views if options[:with_admin_ui] || options[:with_frontend] || @full
  44. create_assets if @full
  45. create_jobs if @full
  46. create_tests if @full
  47. create_readme
  48. create_database_record
  49. say "\n✓ Plugin '#{@plugin_name}' created successfully!", :green
  50. say "\nNext steps:", :yellow
  51. say " 1. Run: rails db:migrate"
  52. say " 2. Activate plugin in admin panel at /admin/plugins"
  53. say " 3. Configure plugin settings"
  54. say " 4. Restart Rails server\n"
  55. end
  56. private
  57. def create_plugin_class
  58. template_file = 'plugin_template.rb'
  59. destination = "lib/plugins/#{@plugin_underscore}/#{@plugin_underscore}.rb"
  60. content = <<~RUBY
  61. # #{@plugin_class} - #{@description}
  62. #
  63. # A professional RailsPress plugin with full MVC support
  64. class #{@plugin_class} < Railspress::PluginBase
  65. plugin_name '#{@plugin_name}'
  66. plugin_version '1.0.0'
  67. plugin_description '#{@description}'
  68. plugin_author '#{@author}'
  69. plugin_url 'https://example.com/plugins/#{@plugin_underscore}'
  70. plugin_license 'GPL-2.0'
  71. def setup
  72. # ========================================
  73. # SETTINGS
  74. # ========================================
  75. define_setting :enabled,
  76. type: 'boolean',
  77. label: 'Enable Plugin',
  78. description: 'Enable or disable this plugin',
  79. default: true
  80. define_setting :api_key,
  81. type: 'string',
  82. label: 'API Key',
  83. description: 'Your API key for external services',
  84. placeholder: 'sk-...',
  85. required: false
  86. # ========================================
  87. # ADMIN PAGES
  88. # ========================================
  89. register_admin_page(
  90. slug: 'dashboard',
  91. title: '#{@plugin_name} Dashboard',
  92. menu_title: 'Dashboard',
  93. icon: 'chart-bar',
  94. callback: :render_dashboard
  95. )
  96. register_admin_page(
  97. slug: 'settings',
  98. title: '#{@plugin_name} Settings',
  99. menu_title: 'Settings',
  100. icon: 'cog'
  101. )
  102. # ========================================
  103. # ROUTES
  104. # ========================================
  105. # Admin routes (scoped under /admin/#{@plugin_underscore})
  106. register_admin_routes do
  107. resources :items do
  108. member do
  109. post :duplicate
  110. end
  111. collection do
  112. get :export
  113. post :import
  114. end
  115. end
  116. get 'dashboard', to: 'dashboard#index'
  117. get 'settings', to: 'settings#index'
  118. patch 'settings', to: 'settings#update'
  119. end
  120. # Frontend routes (scoped under /plugins/#{@plugin_underscore})
  121. register_frontend_routes do
  122. resources :items, only: [:index, :show]
  123. get 'search', to: 'items#search'
  124. end
  125. # ========================================
  126. # ASSETS
  127. # ========================================
  128. register_stylesheet('#{@plugin_underscore}.css', admin_only: true)
  129. register_javascript('#{@plugin_underscore}.js', admin_only: true)
  130. register_stylesheet('#{@plugin_underscore}_frontend.css', frontend_only: true)
  131. register_javascript('#{@plugin_underscore}_frontend.js', frontend_only: true)
  132. # ========================================
  133. # WEBHOOKS & EVENTS
  134. # ========================================
  135. register_webhook('item.created', ENV['WEBHOOK_URL'], {
  136. method: 'POST',
  137. headers: { 'Content-Type' => 'application/json' }
  138. })
  139. on('user.registered') do |data|
  140. log("New user registered: \#{data[:user][:email]}", :info)
  141. end
  142. # ========================================
  143. # BACKGROUND JOBS
  144. # ========================================
  145. schedule_task('daily_cleanup', '0 2 * * *') do
  146. log("Running daily cleanup for #{@plugin_name}", :info)
  147. # Cleanup logic here
  148. end
  149. # ========================================
  150. # HOOKS & FILTERS
  151. # ========================================
  152. add_action('init', :initialize_plugin)
  153. add_filter('post_content', :modify_content)
  154. end
  155. # ========================================
  156. # ACTIVATION / DEACTIVATION
  157. # ========================================
  158. def activate
  159. super
  160. log("Activating #{@plugin_name}", :info)
  161. # Create database tables
  162. create_migrations
  163. # Set default settings
  164. set_setting(:enabled, true)
  165. log("#{@plugin_name} activated successfully", :success)
  166. end
  167. def deactivate
  168. super
  169. log("Deactivating #{@plugin_name}", :info)
  170. end
  171. def uninstall
  172. super
  173. log("Uninstalling #{@plugin_name}", :info)
  174. # Remove database tables
  175. drop_tables
  176. log("#{@plugin_name} uninstalled", :success)
  177. end
  178. # ========================================
  179. # CUSTOM METHODS
  180. # ========================================
  181. def initialize_plugin
  182. log("Initializing #{@plugin_name}", :debug)
  183. end
  184. def modify_content(content)
  185. # Modify post content
  186. content
  187. end
  188. def render_dashboard
  189. # Custom dashboard rendering logic
  190. items_count = #{@plugin_class}Item.count rescue 0
  191. <<~HTML
  192. <div class="space-y-6">
  193. <h1 class="text-2xl font-bold text-white">#{@plugin_name} Dashboard</h1>
  194. <div class="grid grid-cols-3 gap-4">
  195. <div class="bg-[#111111] rounded-lg p-6">
  196. <div class="text-gray-400 text-sm mb-2">Total Items</div>
  197. <div class="text-3xl font-bold text-white">\#{items_count}</div>
  198. </div>
  199. </div>
  200. <div class="bg-[#111111] rounded-lg p-6">
  201. <p class="text-gray-300">Welcome to #{@plugin_name}!</p>
  202. <p class="text-gray-400 mt-2">Configure your settings and start using the plugin.</p>
  203. </div>
  204. </div>
  205. HTML
  206. end
  207. private
  208. def create_migrations
  209. # Create plugin tables
  210. create_plugin_migration('create_#{@plugin_underscore}_items') do |t|
  211. t.string :title, null: false
  212. t.text :description
  213. t.json :metadata, default: {}
  214. t.boolean :active, default: true
  215. t.integer :tenant_id
  216. t.timestamps
  217. t.index :title
  218. t.index :active
  219. t.index :tenant_id
  220. end
  221. end
  222. def drop_tables
  223. # Remove plugin tables
  224. ActiveRecord::Base.connection.drop_table(:#{@plugin_underscore}_items) if table_exists?(:#{@plugin_underscore}_items)
  225. end
  226. def table_exists?(table_name)
  227. ActiveRecord::Base.connection.table_exists?(table_name)
  228. end
  229. end
  230. # Register plugin
  231. Railspress::PluginSystem.register_plugin('#{@plugin_identifier}', #{@plugin_class}.new)
  232. RUBY
  233. create_file destination, content
  234. end
  235. def create_models
  236. return unless options[:with_models] || @full
  237. model_name = "#{@plugin_class}Item"
  238. model_file = "app/models/#{@plugin_underscore}_item.rb"
  239. content = <<~RUBY
  240. # #{model_name} Model
  241. # Belongs to #{@plugin_class} plugin
  242. class #{model_name} < ApplicationRecord
  243. # Multi-tenancy
  244. acts_as_tenant(:tenant, optional: true)
  245. # Associations
  246. belongs_to :user, optional: true
  247. # Validations
  248. validates :title, presence: true
  249. # Scopes
  250. scope :active, -> { where(active: true) }
  251. scope :recent, -> { order(created_at: :desc) }
  252. # Callbacks
  253. before_save :ensure_defaults
  254. after_create :log_creation
  255. # Instance methods
  256. def to_liquid
  257. {
  258. 'id' => id,
  259. 'title' => title,
  260. 'description' => description,
  261. 'active' => active,
  262. 'created_at' => created_at,
  263. 'updated_at' => updated_at
  264. }
  265. end
  266. private
  267. def ensure_defaults
  268. self.metadata ||= {}
  269. end
  270. def log_creation
  271. Rails.logger.info "Created #{model_name}: \#{title} (ID: \#{id})"
  272. end
  273. end
  274. RUBY
  275. create_file model_file, content
  276. end
  277. def create_controllers
  278. create_admin_controller if options[:with_admin_ui] || @full
  279. create_frontend_controller if options[:with_frontend] || @full
  280. end
  281. def create_admin_controller
  282. controller_file = "app/controllers/admin/#{@plugin_underscore}/items_controller.rb"
  283. content = <<~RUBY
  284. # Admin Controller for #{@plugin_class}
  285. # Handles CRUD operations for #{@plugin_class} items
  286. class Admin::#{@plugin_class}::ItemsController < Admin::BaseController
  287. before_action :set_item, only: [:show, :edit, :update, :destroy, :duplicate]
  288. # GET /admin/#{@plugin_underscore}/items
  289. def index
  290. @items = #{@plugin_class}Item.accessible_by(current_tenant)
  291. .recent
  292. .page(params[:page])
  293. end
  294. # GET /admin/#{@plugin_underscore}/items/:id
  295. def show
  296. end
  297. # GET /admin/#{@plugin_underscore}/items/new
  298. def new
  299. @item = #{@plugin_class}Item.new
  300. end
  301. # POST /admin/#{@plugin_underscore}/items
  302. def create
  303. @item = #{@plugin_class}Item.new(item_params)
  304. @item.tenant = current_tenant
  305. @item.user = current_user
  306. if @item.save
  307. redirect_to admin_#{@plugin_underscore}_item_path(@item),
  308. notice: 'Item was successfully created.'
  309. else
  310. render :new, status: :unprocessable_entity
  311. end
  312. end
  313. # GET /admin/#{@plugin_underscore}/items/:id/edit
  314. def edit
  315. end
  316. # PATCH/PUT /admin/#{@plugin_underscore}/items/:id
  317. def update
  318. if @item.update(item_params)
  319. redirect_to admin_#{@plugin_underscore}_item_path(@item),
  320. notice: 'Item was successfully updated.'
  321. else
  322. render :edit, status: :unprocessable_entity
  323. end
  324. end
  325. # DELETE /admin/#{@plugin_underscore}/items/:id
  326. def destroy
  327. @item.destroy
  328. redirect_to admin_#{@plugin_underscore}_items_path,
  329. notice: 'Item was successfully deleted.'
  330. end
  331. # POST /admin/#{@plugin_underscore}/items/:id/duplicate
  332. def duplicate
  333. new_item = @item.dup
  334. new_item.title = "\#{@item.title} (Copy)"
  335. if new_item.save
  336. redirect_to admin_#{@plugin_underscore}_item_path(new_item),
  337. notice: 'Item was successfully duplicated.'
  338. else
  339. redirect_to admin_#{@plugin_underscore}_items_path,
  340. alert: 'Failed to duplicate item.'
  341. end
  342. end
  343. # GET /admin/#{@plugin_underscore}/items/export
  344. def export
  345. @items = #{@plugin_class}Item.accessible_by(current_tenant).all
  346. respond_to do |format|
  347. format.csv do
  348. send_data generate_csv(@items),
  349. filename: "#{@plugin_underscore}_items_\#{Date.today}.csv"
  350. end
  351. format.json do
  352. render json: @items
  353. end
  354. end
  355. end
  356. private
  357. def set_item
  358. @item = #{@plugin_class}Item.accessible_by(current_tenant).find(params[:id])
  359. rescue ActiveRecord::RecordNotFound
  360. redirect_to admin_#{@plugin_underscore}_items_path,
  361. alert: 'Item not found.'
  362. end
  363. def item_params
  364. params.require(:#{@plugin_underscore}_item).permit(
  365. :title, :description, :active, metadata: {}
  366. )
  367. end
  368. def generate_csv(items)
  369. CSV.generate(headers: true) do |csv|
  370. csv << ['ID', 'Title', 'Description', 'Active', 'Created At']
  371. items.each do |item|
  372. csv << [item.id, item.title, item.description, item.active, item.created_at]
  373. end
  374. end
  375. end
  376. end
  377. RUBY
  378. create_file controller_file, content
  379. end
  380. def create_frontend_controller
  381. controller_file = "app/controllers/plugins/#{@plugin_underscore}/items_controller.rb"
  382. content = <<~RUBY
  383. # Frontend Controller for #{@plugin_class}
  384. # Handles public-facing item display
  385. class Plugins::#{@plugin_class}::ItemsController < ApplicationController
  386. before_action :set_item, only: [:show]
  387. # GET /plugins/#{@plugin_underscore}/items
  388. def index
  389. @items = #{@plugin_class}Item.active
  390. .accessible_by(current_tenant)
  391. .recent
  392. .page(params[:page])
  393. end
  394. # GET /plugins/#{@plugin_underscore}/items/:id
  395. def show
  396. end
  397. # GET /plugins/#{@plugin_underscore}/search
  398. def search
  399. @query = params[:q]
  400. @items = #{@plugin_class}Item.active
  401. .accessible_by(current_tenant)
  402. .where('title LIKE ? OR description LIKE ?', "%\#{@query}%", "%\#{@query}%")
  403. .recent
  404. .page(params[:page])
  405. render :index
  406. end
  407. private
  408. def set_item
  409. @item = #{@plugin_class}Item.active
  410. .accessible_by(current_tenant)
  411. .find(params[:id])
  412. rescue ActiveRecord::RecordNotFound
  413. redirect_to plugins_#{@plugin_underscore}_items_path,
  414. alert: 'Item not found.'
  415. end
  416. end
  417. RUBY
  418. create_file controller_file, content
  419. end
  420. def create_views
  421. create_admin_views if options[:with_admin_ui] || @full
  422. create_frontend_views if options[:with_frontend] || @full
  423. end
  424. def create_admin_views
  425. # Index view
  426. create_file "app/views/admin/#{@plugin_underscore}/items/index.html.erb", <<~ERB
  427. <div class="space-y-6">
  428. <div class="flex items-center justify-between">
  429. <h1 class="text-2xl font-bold text-white">#{@plugin_name} Items</h1>
  430. <%= link_to new_admin_#{@plugin_underscore}_item_path,
  431. class: "btn-primary flex items-center gap-2" do %>
  432. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  433. <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
  434. </svg>
  435. New Item
  436. <% end %>
  437. </div>
  438. <div class="bg-[#111111] rounded-lg overflow-hidden">
  439. <table class="w-full">
  440. <thead class="bg-[#0a0a0a] border-b border-[#2a2a2a]">
  441. <tr>
  442. <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Title</th>
  443. <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
  444. <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Created</th>
  445. <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Actions</th>
  446. </tr>
  447. </thead>
  448. <tbody class="divide-y divide-[#2a2a2a]">
  449. <% @items.each do |item| %>
  450. <tr class="hover:bg-[#1a1a1a]">
  451. <td class="px-6 py-4">
  452. <%= link_to item.title, admin_#{@plugin_underscore}_item_path(item),
  453. class: "text-white hover:text-blue-400" %>
  454. </td>
  455. <td class="px-6 py-4">
  456. <% if item.active? %>
  457. <span class="px-2 py-1 text-xs rounded bg-green-900/20 text-green-400">Active</span>
  458. <% else %>
  459. <span class="px-2 py-1 text-xs rounded bg-gray-700 text-gray-400">Inactive</span>
  460. <% end %>
  461. </td>
  462. <td class="px-6 py-4 text-gray-300">
  463. <%= time_ago_in_words(item.created_at) %> ago
  464. </td>
  465. <td class="px-6 py-4 text-right">
  466. <%= link_to 'Edit', edit_admin_#{@plugin_underscore}_item_path(item),
  467. class: "text-blue-400 hover:text-blue-300 mr-3" %>
  468. <%= link_to 'Delete', admin_#{@plugin_underscore}_item_path(item),
  469. data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' },
  470. class: "text-red-400 hover:text-red-300" %>
  471. </td>
  472. </tr>
  473. <% end %>
  474. </tbody>
  475. </table>
  476. </div>
  477. <%= paginate @items %>
  478. </div>
  479. ERB
  480. # New/Edit form
  481. create_file "app/views/admin/#{@plugin_underscore}/items/_form.html.erb", <<~ERB
  482. <%= form_with model: [:admin, :#{@plugin_underscore}, @item], local: true, class: "space-y-6" do |f| %>
  483. <% if @item.errors.any? %>
  484. <div class="bg-red-900/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
  485. <ul class="list-disc list-inside">
  486. <% @item.errors.full_messages.each do |message| %>
  487. <li><%= message %></li>
  488. <% end %>
  489. </ul>
  490. </div>
  491. <% end %>
  492. <div>
  493. <%= f.label :title, class: "block text-sm font-medium text-gray-300 mb-2" %>
  494. <%= f.text_field :title,
  495. class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg text-white focus:border-blue-500",
  496. placeholder: "Enter title",
  497. required: true %>
  498. </div>
  499. <div>
  500. <%= f.label :description, class: "block text-sm font-medium text-gray-300 mb-2" %>
  501. <%= f.text_area :description,
  502. rows: 4,
  503. class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg text-white focus:border-blue-500",
  504. placeholder: "Enter description" %>
  505. </div>
  506. <div class="flex items-center">
  507. <%= f.check_box :active, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-600 rounded" %>
  508. <%= f.label :active, "Active", class: "ml-2 text-sm text-gray-300" %>
  509. </div>
  510. <div class="flex gap-3">
  511. <%= f.submit "Save", class: "btn-primary" %>
  512. <%= link_to "Cancel", admin_#{@plugin_underscore}_items_path, class: "btn-secondary" %>
  513. </div>
  514. <% end %>
  515. ERB
  516. create_file "app/views/admin/#{@plugin_underscore}/items/new.html.erb", <<~ERB
  517. <div class="max-w-3xl mx-auto">
  518. <h1 class="text-2xl font-bold text-white mb-6">New Item</h1>
  519. <%= render 'form' %>
  520. </div>
  521. ERB
  522. create_file "app/views/admin/#{@plugin_underscore}/items/edit.html.erb", <<~ERB
  523. <div class="max-w-3xl mx-auto">
  524. <h1 class="text-2xl font-bold text-white mb-6">Edit Item</h1>
  525. <%= render 'form' %>
  526. </div>
  527. ERB
  528. end
  529. def create_frontend_views
  530. create_file "app/views/plugins/#{@plugin_underscore}/items/index.html.erb", <<~ERB
  531. <div class="max-w-6xl mx-auto py-8 px-4">
  532. <h1 class="text-4xl font-bold mb-8">#{@plugin_name} Items</h1>
  533. <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
  534. <% @items.each do |item| %>
  535. <div class="bg-white rounded-lg shadow-lg overflow-hidden">
  536. <div class="p-6">
  537. <h2 class="text-2xl font-bold mb-2">
  538. <%= link_to item.title, plugins_#{@plugin_underscore}_item_path(item),
  539. class: "text-gray-900 hover:text-blue-600" %>
  540. </h2>
  541. <% if item.description.present? %>
  542. <p class="text-gray-600 mb-4">
  543. <%= truncate(item.description, length: 150) %>
  544. </p>
  545. <% end %>
  546. <div class="flex items-center justify-between">
  547. <span class="text-sm text-gray-500">
  548. <%= time_ago_in_words(item.created_at) %> ago
  549. </span>
  550. <%= link_to 'View', plugins_#{@plugin_underscore}_item_path(item),
  551. class: "text-blue-600 hover:text-blue-700 font-medium" %>
  552. </div>
  553. </div>
  554. </div>
  555. <% end %>
  556. </div>
  557. <div class="mt-8">
  558. <%= paginate @items %>
  559. </div>
  560. </div>
  561. ERB
  562. create_file "app/views/plugins/#{@plugin_underscore}/items/show.html.erb", <<~ERB
  563. <div class="max-w-4xl mx-auto py-8 px-4">
  564. <div class="bg-white rounded-lg shadow-lg p-8">
  565. <h1 class="text-4xl font-bold mb-4"><%= @item.title %></h1>
  566. <div class="text-sm text-gray-500 mb-6">
  567. <%= @item.created_at.strftime('%B %d, %Y') %>
  568. </div>
  569. <% if @item.description.present? %>
  570. <div class="prose max-w-none">
  571. <%= simple_format(@item.description) %>
  572. </div>
  573. <% end %>
  574. <div class="mt-8 pt-6 border-t">
  575. <%= link_to '← Back to Items', plugins_#{@plugin_underscore}_items_path,
  576. class: "text-blue-600 hover:text-blue-700" %>
  577. </div>
  578. </div>
  579. </div>
  580. ERB
  581. end
  582. def create_assets
  583. # Create asset directories and files
  584. empty_directory "lib/plugins/#{@plugin_underscore}/assets/stylesheets"
  585. empty_directory "lib/plugins/#{@plugin_underscore}/assets/javascripts"
  586. empty_directory "lib/plugins/#{@plugin_underscore}/assets/images"
  587. create_file "lib/plugins/#{@plugin_underscore}/assets/stylesheets/#{@plugin_underscore}.css", <<~CSS
  588. /* Admin styles for #{@plugin_name} */
  589. .#{@plugin_underscore}-container {
  590. padding: 1rem;
  591. }
  592. .#{@plugin_underscore}-card {
  593. background: #111111;
  594. border-radius: 0.5rem;
  595. padding: 1.5rem;
  596. }
  597. CSS
  598. create_file "lib/plugins/#{@plugin_underscore}/assets/javascripts/#{@plugin_underscore}.js", <<~JS
  599. // Admin JavaScript for #{@plugin_name}
  600. document.addEventListener('turbo:load', function() {
  601. console.log('#{@plugin_name} loaded');
  602. // Initialize plugin functionality
  603. init#{@plugin_class}();
  604. });
  605. function init#{@plugin_class}() {
  606. // Plugin initialization code
  607. }
  608. JS
  609. create_file "lib/plugins/#{@plugin_underscore}/assets/stylesheets/#{@plugin_underscore}_frontend.css", <<~CSS
  610. /* Frontend styles for #{@plugin_name} */
  611. .#{@plugin_underscore}-item {
  612. margin-bottom: 1rem;
  613. }
  614. CSS
  615. create_file "lib/plugins/#{@plugin_underscore}/assets/javascripts/#{@plugin_underscore}_frontend.js", <<~JS
  616. // Frontend JavaScript for #{@plugin_name}
  617. document.addEventListener('DOMContentLoaded', function() {
  618. console.log('#{@plugin_name} frontend loaded');
  619. });
  620. JS
  621. end
  622. def create_jobs
  623. create_file "app/jobs/#{@plugin_underscore}_job.rb", <<~RUBY
  624. # Background job for #{@plugin_name}
  625. class #{@plugin_class}Job < ApplicationJob
  626. queue_as :default
  627. def perform(*args)
  628. # Job logic here
  629. Rails.logger.info "Executing #{@plugin_class}Job"
  630. end
  631. end
  632. RUBY
  633. end
  634. def create_tests
  635. # Model tests
  636. create_file "test/models/#{@plugin_underscore}_item_test.rb", <<~RUBY
  637. require 'test_helper'
  638. class #{@plugin_class}ItemTest < ActiveSupport::TestCase
  639. test "should create item" do
  640. item = #{@plugin_class}Item.new(title: 'Test Item')
  641. assert item.save
  642. end
  643. test "should require title" do
  644. item = #{@plugin_class}Item.new
  645. assert_not item.save
  646. assert_includes item.errors[:title], "can't be blank"
  647. end
  648. end
  649. RUBY
  650. # Controller tests
  651. create_file "test/controllers/admin/#{@plugin_underscore}/items_controller_test.rb", <<~RUBY
  652. require 'test_helper'
  653. class Admin::#{@plugin_class}::ItemsControllerTest < ActionDispatch::IntegrationTest
  654. setup do
  655. @admin = users(:admin)
  656. sign_in @admin
  657. end
  658. test "should get index" do
  659. get admin_#{@plugin_underscore}_items_url
  660. assert_response :success
  661. end
  662. test "should create item" do
  663. assert_difference('#{@plugin_class}Item.count') do
  664. post admin_#{@plugin_underscore}_items_url, params: {
  665. #{@plugin_underscore}_item: { title: 'Test Item' }
  666. }
  667. end
  668. assert_redirected_to admin_#{@plugin_underscore}_item_path(#{@plugin_class}Item.last)
  669. end
  670. end
  671. RUBY
  672. end
  673. def create_readme
  674. create_file "lib/plugins/#{@plugin_underscore}/README.md", <<~MD
  675. # #{@plugin_name}
  676. #{@description}
  677. ## Installation
  678. 1. The plugin is installed in `lib/plugins/#{@plugin_underscore}/`
  679. 2. Run migrations: `rails db:migrate`
  680. 3. Activate the plugin in admin panel at `/admin/plugins`
  681. 4. Configure settings at `/admin/plugins/#{@plugin_underscore}/settings`
  682. ## Features
  683. - Full CRUD operations for items
  684. - Admin and frontend interfaces
  685. - Multi-tenant support
  686. - Background job processing
  687. - Webhook integration
  688. - Event listeners
  689. ## Configuration
  690. Configure plugin settings in the admin panel:
  691. - **Enable Plugin**: Turn the plugin on/off
  692. - **API Key**: Configure external service integration
  693. ## Usage
  694. ### Admin Interface
  695. Access admin features at `/admin/#{@plugin_underscore}`
  696. ### Frontend Interface
  697. Access public features at `/plugins/#{@plugin_underscore}/items`
  698. ## Development
  699. ### File Structure
  700. ```
  701. lib/plugins/#{@plugin_underscore}/
  702. ├── #{@plugin_underscore}.rb # Main plugin class
  703. ├── assets/ # Plugin assets
  704. ├── README.md # This file
  705. app/
  706. ├── controllers/
  707. │ ├── admin/#{@plugin_underscore}/
  708. │ └── plugins/#{@plugin_underscore}/
  709. ├── views/
  710. │ ├── admin/#{@plugin_underscore}/
  711. │ └── plugins/#{@plugin_underscore}/
  712. ├── models/
  713. │ └── #{@plugin_underscore}_item.rb
  714. └── jobs/
  715. └── #{@plugin_underscore}_job.rb
  716. ```
  717. ### Testing
  718. Run tests:
  719. ```bash
  720. rails test test/models/#{@plugin_underscore}_item_test.rb
  721. rails test test/controllers/admin/#{@plugin_underscore}/
  722. ```
  723. ## API
  724. ### Admin Routes
  725. - `GET /admin/#{@plugin_underscore}/items` - List items
  726. - `POST /admin/#{@plugin_underscore}/items` - Create item
  727. - `GET /admin/#{@plugin_underscore}/items/:id` - Show item
  728. - `PATCH /admin/#{@plugin_underscore}/items/:id` - Update item
  729. - `DELETE /admin/#{@plugin_underscore}/items/:id` - Delete item
  730. ### Frontend Routes
  731. - `GET /plugins/#{@plugin_underscore}/items` - List items
  732. - `GET /plugins/#{@plugin_underscore}/items/:id` - Show item
  733. ## License
  734. GPL-2.0
  735. ## Author
  736. #{@author}
  737. MD
  738. end
  739. def create_database_record
  740. say "\n Creating database record for plugin...", :yellow
  741. # This will be run after generation
  742. # The actual database record should be created manually or via rake task
  743. end
  744. end

lib/plugins/PLUGIN_TEMPLATE.rb

0.0% lines covered

100.0% branches covered

66 relevant lines. 0 lines covered and 66 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # [Plugin Name] Plugin for RailsPress
  2. # [Brief description of what this plugin does]
  3. #
  4. # Features:
  5. # - Feature 1
  6. # - Feature 2
  7. # - Feature 3
  8. #
  9. # Settings:
  10. # - setting_name (type): Description
  11. #
  12. # Hooks Registered:
  13. # - action_name: Description
  14. # - filter_name: Description
  15. class PluginTemplate < Railspress::PluginBase
  16. # Plugin Metadata (required)
  17. plugin_name 'Plugin Template'
  18. plugin_version '1.0.0'
  19. plugin_description 'A template for creating RailsPress plugins'
  20. plugin_author 'Your Name'
  21. plugin_url 'https://github.com/yourname/plugin-name' # optional
  22. plugin_license 'MIT' # optional
  23. # Plugin configuration (optional)
  24. def self.default_settings
  25. {
  26. 'enabled' => true,
  27. 'setting_1' => 'default_value',
  28. 'setting_2' => 10
  29. }
  30. end
  31. # Activation hook (required)
  32. def activate
  33. super # Always call super first
  34. Rails.logger.info "#{plugin_name} v#{plugin_version} activated"
  35. # Initialize plugin
  36. register_hooks
  37. register_filters
  38. register_shortcodes if respond_to?(:register_shortcodes, true)
  39. inject_helpers if respond_to?(:inject_helpers, true)
  40. # Run one-time setup tasks
  41. perform_activation_tasks
  42. end
  43. # Deactivation hook (required)
  44. def deactivate
  45. super # Always call super first
  46. Rails.logger.info "#{plugin_name} deactivated"
  47. # Cleanup
  48. cleanup_hooks
  49. cleanup_shortcodes if respond_to?(:cleanup_shortcodes, true)
  50. end
  51. private
  52. # Register action hooks
  53. def register_hooks
  54. # Examples:
  55. # add_action('post_created', :on_post_created)
  56. # add_action('page_published', :on_page_published)
  57. # add_action('comment_approved', :on_comment_approved)
  58. end
  59. # Register filters
  60. def register_filters
  61. # Examples:
  62. # add_filter('post_content', :modify_post_content)
  63. # add_filter('page_title', :modify_page_title)
  64. end
  65. # Register shortcodes (optional)
  66. def register_shortcodes
  67. # Example:
  68. # register_shortcode('my_shortcode') do |attrs, content|
  69. # "<div>#{content}</div>"
  70. # end
  71. end
  72. # Inject helper methods (optional)
  73. def inject_helpers
  74. # Example:
  75. # ApplicationController.helper(PluginTemplateHelper)
  76. end
  77. # Perform one-time activation tasks
  78. def perform_activation_tasks
  79. # Examples:
  80. # - Create database records
  81. # - Generate files
  82. # - Set default settings
  83. end
  84. # Cleanup on deactivation
  85. def cleanup_hooks
  86. # Remove registered hooks/filters
  87. # This is usually handled by PluginBase
  88. end
  89. # Hook callback examples
  90. def on_post_created(post_id)
  91. post = Post.find_by(id: post_id)
  92. return unless post
  93. Rails.logger.info "New post created: #{post.title}"
  94. # Add your logic here
  95. end
  96. def on_page_published(page_id)
  97. page = Page.find_by(id: page_id)
  98. return unless page
  99. Rails.logger.info "Page published: #{page.title}"
  100. # Add your logic here
  101. end
  102. # Filter callback examples
  103. def modify_post_content(content)
  104. # Modify and return content
  105. content
  106. end
  107. def modify_page_title(title)
  108. # Modify and return title
  109. title
  110. end
  111. # Public API methods (can be called from anywhere)
  112. def self.do_something(param)
  113. # Your public plugin method
  114. end
  115. # Settings helpers (inherited from PluginBase)
  116. # get_setting(key, default)
  117. # set_setting(key, value)
  118. # setting_enabled?(key)
  119. end
  120. # Helper module (optional)
  121. module PluginTemplateHelper
  122. def plugin_template_method
  123. # Your helper method
  124. end
  125. end
  126. # Initialize the plugin
  127. PluginTemplate.new

lib/plugins/advanced_shortcodes/advanced_shortcodes.rb

0.0% lines covered

100.0% branches covered

193 relevant lines. 0 lines covered and 193 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AdvancedShortcodes < Railspress::PluginBase
  2. plugin_name 'Advanced Shortcodes'
  3. plugin_version '2.0.0'
  4. plugin_description 'Extended shortcode library with schema-based settings'
  5. plugin_author 'RailsPress Team'
  6. # Define comprehensive settings schema showcasing all field types
  7. settings_schema do
  8. section 'Appearance', description: 'Control the visual appearance of shortcodes' do
  9. color 'button_color', 'Default Button Color',
  10. description: 'Default color for [button] shortcodes',
  11. default: '#3B82F6'
  12. color 'accent_color', 'Accent Color',
  13. description: 'Accent color used across shortcodes',
  14. default: '#10B981'
  15. select 'button_style', 'Button Style',
  16. [
  17. ['Rounded', 'rounded'],
  18. ['Square', 'square'],
  19. ['Pill', 'pill']
  20. ],
  21. description: 'Default button border radius style',
  22. default: 'rounded'
  23. number 'button_padding', 'Button Padding (px)',
  24. description: 'Internal padding for buttons',
  25. default: 12,
  26. min: 4,
  27. max: 32
  28. end
  29. section 'Gallery Settings', description: 'Configure gallery shortcode behavior' do
  30. select 'gallery_layout', 'Default Layout',
  31. [
  32. ['Grid', 'grid'],
  33. ['Masonry', 'masonry'],
  34. ['Carousel', 'carousel']
  35. ],
  36. default: 'grid'
  37. number 'gallery_columns', 'Columns',
  38. description: 'Number of columns in grid layout',
  39. default: 3,
  40. min: 1,
  41. max: 6
  42. number 'gallery_spacing', 'Spacing (px)',
  43. description: 'Gap between gallery items',
  44. default: 16,
  45. min: 0,
  46. max: 48
  47. checkbox 'gallery_lightbox', 'Enable Lightbox',
  48. description: 'Open images in a lightbox when clicked',
  49. default: true
  50. checkbox 'gallery_lazy_load', 'Lazy Loading',
  51. description: 'Lazy load gallery images for better performance',
  52. default: true
  53. end
  54. section 'Alert/Callout Settings', description: 'Configure alert box shortcodes' do
  55. radio 'alert_style', 'Alert Style',
  56. [
  57. ['Minimal', 'minimal'],
  58. ['Bordered', 'bordered'],
  59. ['Filled', 'filled']
  60. ],
  61. default: 'bordered'
  62. checkbox 'alert_dismissible', 'Dismissible',
  63. description: 'Allow users to close alerts',
  64. default: true
  65. checkbox 'alert_icons', 'Show Icons',
  66. description: 'Display icons in alert boxes',
  67. default: true
  68. end
  69. section 'Video Settings', description: 'Configure video shortcode options' do
  70. checkbox 'video_responsive', 'Responsive',
  71. description: 'Make videos responsive (16:9 aspect ratio)',
  72. default: true
  73. checkbox 'video_autoplay', 'Auto-play by Default',
  74. description: 'Videos auto-play when page loads (not recommended)',
  75. default: false
  76. checkbox 'video_controls', 'Show Controls',
  77. description: 'Display video playback controls',
  78. default: true
  79. select 'video_preload', 'Preload Strategy',
  80. [
  81. ['None', 'none'],
  82. ['Metadata', 'metadata'],
  83. ['Auto', 'auto']
  84. ],
  85. description: 'How much of the video to preload',
  86. default: 'metadata'
  87. end
  88. section 'Advanced Options', description: 'Advanced configuration' do
  89. code 'custom_css', 'Custom CSS',
  90. description: 'Add custom CSS for shortcodes',
  91. language: 'css',
  92. placeholder: '.my-shortcode { color: red; }'
  93. textarea 'custom_javascript', 'Custom JavaScript',
  94. description: 'Add custom JavaScript for shortcodes (use carefully!)',
  95. rows: 6,
  96. placeholder: 'console.log("Shortcodes loaded");'
  97. checkbox 'debug_mode', 'Debug Mode',
  98. description: 'Log shortcode rendering details to console',
  99. default: false
  100. checkbox 'cache_output', 'Cache Output',
  101. description: 'Cache rendered shortcode output for performance',
  102. default: true
  103. end
  104. end
  105. def initialize
  106. super
  107. register_shortcodes if get_setting('enabled', true)
  108. end
  109. def activate
  110. super
  111. Rails.logger.info "Advanced Shortcodes activated with schema settings"
  112. end
  113. private
  114. def register_shortcodes
  115. # Button shortcode
  116. register_shortcode('button') do |atts, content|
  117. atts = {
  118. 'url' => '#',
  119. 'color' => get_setting('button_color', '#3B82F6'),
  120. 'style' => get_setting('button_style', 'rounded'),
  121. 'size' => 'medium'
  122. }.merge(atts || {})
  123. radius = case atts['style']
  124. when 'pill' then '9999px'
  125. when 'square' then '0'
  126. else '6px'
  127. end
  128. padding = get_setting('button_padding', 12)
  129. <<~HTML
  130. <a href="#{atts['url']}"
  131. class="inline-block"
  132. style="background: #{atts['color']}; color: white; padding: #{padding}px #{padding * 2}px; border-radius: #{radius}; text-decoration: none; font-weight: 500;">
  133. #{content}
  134. </a>
  135. HTML
  136. end
  137. # Gallery shortcode
  138. register_shortcode('gallery') do |atts, content|
  139. layout = get_setting('gallery_layout', 'grid')
  140. columns = get_setting('gallery_columns', 3)
  141. spacing = get_setting('gallery_spacing', 16)
  142. <<~HTML
  143. <div class="gallery-#{layout}" style="display: grid; grid-template-columns: repeat(#{columns}, 1fr); gap: #{spacing}px;">
  144. <!-- Gallery items would go here -->
  145. <div style="text-align: center; padding: 20px; background: #f3f4f6; border-radius: 8px;">
  146. Gallery placeholder (#{columns} columns)
  147. </div>
  148. </div>
  149. HTML
  150. end
  151. # Alert shortcode
  152. register_shortcode('alert') do |atts, content|
  153. atts = {
  154. 'type' => 'info',
  155. 'style' => get_setting('alert_style', 'bordered'),
  156. 'dismissible' => get_setting('alert_dismissible', true)
  157. }.merge(atts || {})
  158. colors = {
  159. 'info' => '#3B82F6',
  160. 'success' => '#10B981',
  161. 'warning' => '#F59E0B',
  162. 'error' => '#EF4444'
  163. }
  164. color = colors[atts['type']] || colors['info']
  165. <<~HTML
  166. <div class="alert alert-#{atts['type']}" style="border-left: 4px solid #{color}; background: rgba(59, 130, 246, 0.1); padding: 1rem; border-radius: 0.5rem; margin: 1rem 0;">
  167. #{content}
  168. #{atts['dismissible'] ? '<button onclick="this.parentElement.remove()" style="float: right;">×</button>' : ''}
  169. </div>
  170. HTML
  171. end
  172. # Video shortcode
  173. register_shortcode('video') do |atts, content|
  174. atts = {
  175. 'src' => '',
  176. 'responsive' => get_setting('video_responsive', true),
  177. 'autoplay' => get_setting('video_autoplay', false),
  178. 'controls' => get_setting('video_controls', true)
  179. }.merge(atts || {})
  180. if atts['responsive']
  181. <<~HTML
  182. <div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
  183. <video src="#{atts['src']}"
  184. #{atts['controls'] ? 'controls' : ''}
  185. #{atts['autoplay'] ? 'autoplay' : ''}
  186. style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
  187. </video>
  188. </div>
  189. HTML
  190. else
  191. <<~HTML
  192. <video src="#{atts['src']}"
  193. #{atts['controls'] ? 'controls' : ''}
  194. #{atts['autoplay'] ? 'autoplay' : ''}
  195. style="max-width: 100%;">
  196. </video>
  197. HTML
  198. end
  199. end
  200. Rails.logger.info "Advanced Shortcodes registered with schema-based settings"
  201. end
  202. end
  203. # Auto-initialize
  204. if Plugin.exists?(name: 'Advanced Shortcodes', active: true)
  205. AdvancedShortcodes.new
  206. end

lib/plugins/ai_seo/ai_seo.rb

0.0% lines covered

100.0% branches covered

430 relevant lines. 0 lines covered and 430 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class AiSeo < Railspress::PluginBase
  2. plugin_name 'AI SEO'
  3. plugin_version '1.0.0'
  4. plugin_description 'Automatically generate and optimize SEO meta tags using AI'
  5. plugin_author 'RailsPress Team'
  6. # Comprehensive settings schema for AI SEO
  7. settings_schema do
  8. section 'AI Provider', description: 'Configure your AI service provider' do
  9. select 'ai_provider', 'AI Provider',
  10. [
  11. ['OpenAI (GPT-4, GPT-3.5)', 'openai'],
  12. ['Anthropic (Claude)', 'anthropic'],
  13. ['Google (Gemini)', 'google'],
  14. ['Custom API', 'custom']
  15. ],
  16. description: 'Choose your AI service provider',
  17. required: true,
  18. default: 'openai'
  19. text 'api_key', 'API Key',
  20. description: 'Your AI provider API key',
  21. required: true,
  22. placeholder: 'sk-...'
  23. select 'model', 'Model',
  24. [
  25. ['GPT-4 Turbo', 'gpt-4-turbo-preview'],
  26. ['GPT-4', 'gpt-4'],
  27. ['GPT-3.5 Turbo', 'gpt-3.5-turbo'],
  28. ['Claude 3 Opus', 'claude-3-opus-20240229'],
  29. ['Claude 3 Sonnet', 'claude-3-sonnet-20240229'],
  30. ['Claude 3 Haiku', 'claude-3-haiku-20240307'],
  31. ['Gemini Pro', 'gemini-pro']
  32. ],
  33. description: 'Select the AI model to use',
  34. default: 'gpt-3.5-turbo'
  35. url 'custom_api_url', 'Custom API URL',
  36. description: 'Custom API endpoint (only if using Custom API)',
  37. placeholder: 'https://api.example.com/v1/chat'
  38. end
  39. section 'Auto-Generation Settings', description: 'Configure when and how SEO is generated' do
  40. checkbox 'auto_generate_on_save', 'Auto-Generate on Save',
  41. description: 'Automatically generate SEO when content is saved',
  42. default: true
  43. checkbox 'auto_generate_on_publish', 'Auto-Generate on Publish',
  44. description: 'Generate SEO when content is published',
  45. default: true
  46. checkbox 'overwrite_existing', 'Overwrite Existing Meta Tags',
  47. description: 'Replace existing meta tags with AI-generated ones',
  48. default: false
  49. checkbox 'generate_meta_title', 'Generate Meta Title',
  50. description: 'Auto-generate meta title',
  51. default: true
  52. checkbox 'generate_meta_description', 'Generate Meta Description',
  53. description: 'Auto-generate meta description',
  54. default: true
  55. checkbox 'generate_meta_keywords', 'Generate Meta Keywords',
  56. description: 'Auto-generate meta keywords',
  57. default: true
  58. checkbox 'generate_og_tags', 'Generate Open Graph Tags',
  59. description: 'Auto-generate Open Graph title and description',
  60. default: true
  61. checkbox 'generate_twitter_tags', 'Generate Twitter Card Tags',
  62. description: 'Auto-generate Twitter card metadata',
  63. default: true
  64. checkbox 'generate_focus_keyphrase', 'Generate Focus Keyphrase',
  65. description: 'Identify and set focus keyphrase',
  66. default: true
  67. end
  68. section 'SEO Guidelines', description: 'Configure SEO best practices and limits' do
  69. number 'meta_title_max_length', 'Meta Title Max Length',
  70. description: 'Maximum characters for meta title',
  71. default: 60,
  72. min: 30,
  73. max: 100
  74. number 'meta_description_max_length', 'Meta Description Max Length',
  75. description: 'Maximum characters for meta description',
  76. default: 160,
  77. min: 100,
  78. max: 320
  79. number 'meta_keywords_count', 'Number of Keywords',
  80. description: 'How many keywords to generate',
  81. default: 5,
  82. min: 3,
  83. max: 10
  84. select 'tone', 'Content Tone',
  85. [
  86. ['Professional', 'professional'],
  87. ['Casual', 'casual'],
  88. ['Technical', 'technical'],
  89. ['Marketing', 'marketing'],
  90. ['Educational', 'educational']
  91. ],
  92. description: 'Tone for meta descriptions',
  93. default: 'professional'
  94. end
  95. section 'Content Analysis', description: 'AI content analysis settings' do
  96. checkbox 'analyze_readability', 'Analyze Readability',
  97. description: 'Check content readability score',
  98. default: true
  99. checkbox 'analyze_keyword_density', 'Analyze Keyword Density',
  100. description: 'Calculate keyword density',
  101. default: true
  102. checkbox 'analyze_sentiment', 'Analyze Sentiment',
  103. description: 'Determine content sentiment',
  104. default: false
  105. checkbox 'suggest_improvements', 'Suggest Improvements',
  106. description: 'Provide SEO improvement suggestions',
  107. default: true
  108. end
  109. section 'Rate Limiting', description: 'Control API usage' do
  110. number 'max_requests_per_hour', 'Max Requests Per Hour',
  111. description: 'Limit API calls to prevent excessive usage',
  112. default: 100,
  113. min: 10,
  114. max: 1000
  115. number 'retry_attempts', 'Retry Attempts',
  116. description: 'Number of retries on API failure',
  117. default: 3,
  118. min: 1,
  119. max: 5
  120. number 'timeout_seconds', 'Timeout (seconds)',
  121. description: 'API request timeout',
  122. default: 30,
  123. min: 10,
  124. max: 120
  125. end
  126. section 'Advanced', description: 'Advanced configuration options' do
  127. textarea 'custom_prompt', 'Custom AI Prompt',
  128. description: 'Customize the AI prompt (leave blank for default)',
  129. rows: 6,
  130. placeholder: 'You are an SEO expert. Analyze the following content and provide...'
  131. checkbox 'log_ai_responses', 'Log AI Responses',
  132. description: 'Save AI responses for debugging',
  133. default: false
  134. checkbox 'use_cache', 'Use Response Cache',
  135. description: 'Cache AI responses to reduce API calls',
  136. default: true
  137. number 'cache_ttl_hours', 'Cache TTL (hours)',
  138. description: 'How long to cache responses',
  139. default: 24,
  140. min: 1,
  141. max: 168
  142. end
  143. end
  144. def initialize
  145. super
  146. register_hooks if enabled?
  147. register_ui_blocks
  148. end
  149. def activate
  150. super
  151. validate_configuration
  152. Rails.logger.info "AI SEO plugin activated"
  153. end
  154. def enabled?
  155. get_setting('api_key').present?
  156. end
  157. # Main API: Generate SEO for content
  158. def generate_seo_for(content_object)
  159. return unless should_generate?(content_object)
  160. begin
  161. # Extract content
  162. content_text = extract_content_text(content_object)
  163. # Check rate limit
  164. return if rate_limit_exceeded?
  165. # Check cache
  166. cache_key = cache_key_for(content_object)
  167. if get_setting('use_cache', true) && cached_response = fetch_from_cache(cache_key)
  168. return apply_seo_data(content_object, cached_response)
  169. end
  170. # Call AI API
  171. ai_response = call_ai_api(content_text, content_object)
  172. # Parse and apply SEO
  173. seo_data = parse_ai_response(ai_response)
  174. apply_seo_data(content_object, seo_data)
  175. # Cache response
  176. cache_response(cache_key, seo_data) if get_setting('use_cache', true)
  177. # Log if enabled
  178. log_ai_interaction(content_object, ai_response) if get_setting('log_ai_responses', false)
  179. increment_request_count
  180. Rails.logger.info "AI SEO generated for #{content_object.class.name} ##{content_object.id}"
  181. true
  182. rescue => e
  183. Rails.logger.error "AI SEO generation failed: #{e.message}"
  184. false
  185. end
  186. end
  187. # Public API endpoint for manual generation
  188. def self.generate_seo(content_type, content_id)
  189. plugin = Railspress::PluginSystem.get_plugin('ai_seo')
  190. return { success: false, error: 'Plugin not active' } unless plugin
  191. content = find_content(content_type, content_id)
  192. return { success: false, error: 'Content not found' } unless content
  193. result = plugin.generate_seo_for(content)
  194. if result
  195. {
  196. success: true,
  197. meta_title: content.meta_title,
  198. meta_description: content.meta_description,
  199. meta_keywords: content.meta_keywords,
  200. focus_keyphrase: content.focus_keyphrase
  201. }
  202. else
  203. { success: false, error: 'Generation failed' }
  204. end
  205. end
  206. private
  207. def register_hooks
  208. # Hook into post/page save
  209. if get_setting('auto_generate_on_save', true)
  210. add_action('post_saved', 20) { |post| generate_seo_for(post) }
  211. add_action('page_saved', 20) { |page| generate_seo_for(page) }
  212. end
  213. # Hook into publish
  214. if get_setting('auto_generate_on_publish', true)
  215. add_action('post_published', 20) { |post| generate_seo_for(post) }
  216. add_action('page_published', 20) { |page| generate_seo_for(page) }
  217. end
  218. end
  219. def register_ui_blocks
  220. # Register a sidebar block for SEO analysis on post/page edit screens
  221. register_block(:ai_seo_analyzer, {
  222. label: 'AI SEO Analyzer',
  223. description: 'AI-powered SEO analysis and optimization suggestions',
  224. icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
  225. locations: [:post, :page],
  226. position: :sidebar,
  227. order: 5,
  228. partial: 'plugins/ai_seo/analyzer_block',
  229. can_render: ->(context) { context[:current_user]&.admin? || context[:current_user]&.editor? }
  230. })
  231. # Register a toolbar block for quick SEO actions
  232. register_block(:ai_seo_toolbar, {
  233. label: 'AI SEO Tools',
  234. description: 'Quick SEO generation actions',
  235. icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>',
  236. locations: [:post, :page],
  237. position: :toolbar,
  238. order: 10,
  239. partial: 'plugins/ai_seo/toolbar_block'
  240. })
  241. end
  242. def should_generate?(content)
  243. return false unless content.respond_to?(:meta_title)
  244. # Check if we should overwrite
  245. unless get_setting('overwrite_existing', false)
  246. return false if content.meta_title.present?
  247. end
  248. # Check if content has substance
  249. text = extract_content_text(content)
  250. text.present? && text.length > 100
  251. end
  252. def extract_content_text(content)
  253. text = []
  254. text << content.title if content.respond_to?(:title)
  255. if content.respond_to?(:content) && content.content.present?
  256. text << content.content.to_plain_text
  257. elsif content.respond_to?(:body)
  258. text << content.body
  259. end
  260. text.join("\n\n").strip
  261. end
  262. def call_ai_api(content_text, content_object)
  263. provider = get_setting('ai_provider', 'openai')
  264. case provider
  265. when 'openai'
  266. call_openai_api(content_text, content_object)
  267. when 'anthropic'
  268. call_anthropic_api(content_text, content_object)
  269. when 'google'
  270. call_google_api(content_text, content_object)
  271. when 'custom'
  272. call_custom_api(content_text, content_object)
  273. else
  274. raise "Unsupported AI provider: #{provider}"
  275. end
  276. end
  277. def call_openai_api(content_text, content_object)
  278. require 'net/http'
  279. require 'json'
  280. api_key = get_setting('api_key')
  281. model = get_setting('model', 'gpt-3.5-turbo')
  282. uri = URI('https://api.openai.com/v1/chat/completions')
  283. request = Net::HTTP::Post.new(uri)
  284. request['Authorization'] = "Bearer #{api_key}"
  285. request['Content-Type'] = 'application/json'
  286. prompt = build_seo_prompt(content_text, content_object)
  287. request.body = {
  288. model: model,
  289. messages: [
  290. { role: 'system', content: 'You are an expert SEO specialist. Generate optimized meta tags.' },
  291. { role: 'user', content: prompt }
  292. ],
  293. temperature: 0.7,
  294. max_tokens: 500
  295. }.to_json
  296. response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: get_setting('timeout_seconds', 30)) do |http|
  297. http.request(request)
  298. end
  299. JSON.parse(response.body)
  300. end
  301. def call_anthropic_api(content_text, content_object)
  302. require 'net/http'
  303. require 'json'
  304. api_key = get_setting('api_key')
  305. model = get_setting('model', 'claude-3-sonnet-20240229')
  306. uri = URI('https://api.anthropic.com/v1/messages')
  307. request = Net::HTTP::Post.new(uri)
  308. request['x-api-key'] = api_key
  309. request['anthropic-version'] = '2023-06-01'
  310. request['Content-Type'] = 'application/json'
  311. prompt = build_seo_prompt(content_text, content_object)
  312. request.body = {
  313. model: model,
  314. max_tokens: 500,
  315. messages: [
  316. { role: 'user', content: prompt }
  317. ]
  318. }.to_json
  319. response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: get_setting('timeout_seconds', 30)) do |http|
  320. http.request(request)
  321. end
  322. JSON.parse(response.body)
  323. end
  324. def call_google_api(content_text, content_object)
  325. # Placeholder for Google Gemini API
  326. { error: 'Google Gemini API not yet implemented' }
  327. end
  328. def call_custom_api(content_text, content_object)
  329. # Placeholder for custom API
  330. { error: 'Custom API not yet implemented' }
  331. end
  332. def build_seo_prompt(content_text, content_object)
  333. custom_prompt = get_setting('custom_prompt')
  334. return custom_prompt.gsub('{{content}}', content_text) if custom_prompt.present?
  335. max_title = get_setting('meta_title_max_length', 60)
  336. max_desc = get_setting('meta_description_max_length', 160)
  337. keyword_count = get_setting('meta_keywords_count', 5)
  338. tone = get_setting('tone', 'professional')
  339. <<~PROMPT
  340. Analyze the following content and generate SEO-optimized meta tags.
  341. Content:
  342. ---
  343. #{content_text.truncate(2000)}
  344. ---
  345. Generate the following in JSON format:
  346. {
  347. "meta_title": "SEO-optimized title (max #{max_title} chars)",
  348. "meta_description": "Compelling description (max #{max_desc} chars, #{tone} tone)",
  349. "meta_keywords": "comma-separated keywords (#{keyword_count} keywords)",
  350. "focus_keyphrase": "primary keyword phrase",
  351. "og_title": "Social media optimized title",
  352. "og_description": "Social media description",
  353. "twitter_title": "Twitter card title",
  354. "twitter_description": "Twitter card description",
  355. "suggestions": ["improvement suggestion 1", "improvement suggestion 2"]
  356. }
  357. Guidelines:
  358. - Meta title should be compelling and include the focus keyword
  359. - Meta description should be action-oriented with a clear value proposition
  360. - Keywords should be relevant and specific
  361. - All fields should be within character limits
  362. - Use #{tone} tone
  363. Respond ONLY with valid JSON, no additional text.
  364. PROMPT
  365. end
  366. def parse_ai_response(response)
  367. # Handle OpenAI response format
  368. if response['choices']&.first
  369. content = response['choices'].first['message']['content']
  370. # Extract JSON from response
  371. json_match = content.match(/\{[\s\S]*\}/)
  372. return JSON.parse(json_match[0]) if json_match
  373. end
  374. # Handle Anthropic response format
  375. if response['content']&.first
  376. content = response['content'].first['text']
  377. json_match = content.match(/\{[\s\S]*\}/)
  378. return JSON.parse(json_match[0]) if json_match
  379. end
  380. {}
  381. rescue JSON::ParserError => e
  382. Rails.logger.error "Failed to parse AI response: #{e.message}"
  383. {}
  384. end
  385. def apply_seo_data(content, seo_data)
  386. return unless seo_data.present?
  387. content.meta_title = seo_data['meta_title'] if get_setting('generate_meta_title', true) && seo_data['meta_title']
  388. content.meta_description = seo_data['meta_description'] if get_setting('generate_meta_description', true) && seo_data['meta_description']
  389. content.meta_keywords = seo_data['meta_keywords'] if get_setting('generate_meta_keywords', true) && seo_data['meta_keywords']
  390. content.focus_keyphrase = seo_data['focus_keyphrase'] if get_setting('generate_focus_keyphrase', true) && seo_data['focus_keyphrase']
  391. if get_setting('generate_og_tags', true)
  392. content.og_title = seo_data['og_title'] if seo_data['og_title']
  393. content.og_description = seo_data['og_description'] if seo_data['og_description']
  394. end
  395. if get_setting('generate_twitter_tags', true)
  396. content.twitter_title = seo_data['twitter_title'] if seo_data['twitter_title']
  397. content.twitter_description = seo_data['twitter_description'] if seo_data['twitter_description']
  398. end
  399. content.save(validate: false)
  400. end
  401. def rate_limit_exceeded?
  402. max_requests = get_setting('max_requests_per_hour', 100)
  403. current_count = get_request_count
  404. if current_count >= max_requests
  405. Rails.logger.warn "AI SEO rate limit exceeded: #{current_count}/#{max_requests}"
  406. return true
  407. end
  408. false
  409. end
  410. def get_request_count
  411. key = "ai_seo_requests_#{Time.now.hour}"
  412. Rails.cache.read(key) || 0
  413. end
  414. def increment_request_count
  415. key = "ai_seo_requests_#{Time.now.hour}"
  416. count = get_request_count + 1
  417. Rails.cache.write(key, count, expires_in: 1.hour)
  418. end
  419. def cache_key_for(content)
  420. "ai_seo_#{content.class.name.underscore}_#{content.id}_#{content.updated_at.to_i}"
  421. end
  422. def fetch_from_cache(cache_key)
  423. Rails.cache.read(cache_key)
  424. end
  425. def cache_response(cache_key, seo_data)
  426. ttl_hours = get_setting('cache_ttl_hours', 24)
  427. Rails.cache.write(cache_key, seo_data, expires_in: ttl_hours.hours)
  428. end
  429. def log_ai_interaction(content, response)
  430. Rails.logger.info "AI SEO Interaction Log:"
  431. Rails.logger.info "Content: #{content.class.name} ##{content.id}"
  432. Rails.logger.info "Response: #{response.to_json}"
  433. end
  434. def validate_configuration
  435. unless get_setting('api_key').present?
  436. Rails.logger.warn "AI SEO: API key not configured"
  437. end
  438. end
  439. def self.find_content(content_type, content_id)
  440. case content_type.to_s.downcase
  441. when 'post'
  442. Post.find_by(id: content_id)
  443. when 'page'
  444. Page.find_by(id: content_id)
  445. else
  446. nil
  447. end
  448. end
  449. end
  450. # Auto-initialize if active
  451. if Plugin.exists?(name: 'AI SEO', active: true)
  452. AiSeo.new
  453. end

lib/plugins/email_notifications/email_notifications.rb

0.0% lines covered

100.0% branches covered

136 relevant lines. 0 lines covered and 136 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class EmailNotifications < Railspress::PluginBase
  2. plugin_name 'Email Notifications'
  3. plugin_version '2.0.0'
  4. plugin_description 'Send email notifications for various events using schema-based settings'
  5. plugin_author 'RailsPress Team'
  6. # Define settings schema - this will auto-generate the admin UI!
  7. settings_schema do
  8. section 'General Settings', description: 'Configure basic email notification settings' do
  9. checkbox 'enabled', 'Enable Email Notifications',
  10. description: 'Turn on/off all email notifications',
  11. default: true
  12. email 'admin_email', 'Admin Email Address',
  13. description: 'Email address to receive admin notifications',
  14. required: true,
  15. placeholder: 'admin@example.com'
  16. text 'from_name', 'From Name',
  17. description: 'The name that appears in the From field',
  18. default: 'RailsPress',
  19. placeholder: 'Your Site Name'
  20. end
  21. section 'Post Notifications', description: 'Configure notifications for posts' do
  22. checkbox 'notify_on_new_post', 'New Post Created',
  23. description: 'Send notification when a new post is created',
  24. default: true
  25. checkbox 'notify_on_post_published', 'Post Published',
  26. description: 'Send notification when a post is published',
  27. default: true
  28. select 'post_notification_recipients', 'Recipients',
  29. [
  30. ['Administrators Only', 'administrators'],
  31. ['All Editors', 'editors'],
  32. ['All Users', 'all']
  33. ],
  34. description: 'Who should receive post notifications',
  35. default: 'administrators'
  36. end
  37. section 'Comment Notifications', description: 'Configure notifications for comments' do
  38. checkbox 'notify_on_new_comment', 'New Comment',
  39. description: 'Send notification when a new comment is submitted',
  40. default: true
  41. checkbox 'notify_on_comment_approved', 'Comment Approved',
  42. description: 'Send notification when a comment is approved',
  43. default: false
  44. checkbox 'notify_post_author', 'Notify Post Author',
  45. description: 'Send notification to post author when someone comments',
  46. default: true
  47. end
  48. section 'Advanced Settings', description: 'Advanced configuration options' do
  49. number 'batch_size', 'Batch Size',
  50. description: 'Number of emails to send per batch',
  51. default: 10,
  52. min: 1,
  53. max: 100
  54. number 'delay_between_batches', 'Delay Between Batches (seconds)',
  55. description: 'Wait time between email batches to avoid rate limiting',
  56. default: 5,
  57. min: 0,
  58. max: 60
  59. textarea 'email_template', 'Custom Email Template',
  60. description: 'Custom HTML email template (leave blank for default)',
  61. rows: 8,
  62. placeholder: '<html><body>{{content}}</body></html>'
  63. end
  64. end
  65. # Initialization
  66. def initialize
  67. super
  68. register_hooks if get_setting('enabled', true)
  69. end
  70. def activate
  71. super
  72. Rails.logger.info "Email Notifications plugin activated with schema-based settings"
  73. end
  74. private
  75. def register_hooks
  76. # Post notifications
  77. if get_setting('notify_on_new_post', true)
  78. add_action('post_created', 10) do |post|
  79. send_post_created_notification(post)
  80. end
  81. end
  82. if get_setting('notify_on_post_published', true)
  83. add_action('post_published', 10) do |post|
  84. send_post_published_notification(post)
  85. end
  86. end
  87. # Comment notifications
  88. if get_setting('notify_on_new_comment', true)
  89. add_action('comment_created', 10) do |comment|
  90. send_comment_notification(comment)
  91. end
  92. end
  93. if get_setting('notify_post_author', true)
  94. add_action('comment_created', 15) do |comment|
  95. send_author_notification(comment)
  96. end
  97. end
  98. end
  99. def send_post_created_notification(post)
  100. recipients = get_recipients
  101. return if recipients.empty?
  102. recipients.each do |user|
  103. # TODO: Send email via ActionMailer
  104. Rails.logger.info "Would send 'post created' email to #{user.email}"
  105. end
  106. end
  107. def send_post_published_notification(post)
  108. recipients = get_recipients
  109. return if recipients.empty?
  110. recipients.each do |user|
  111. Rails.logger.info "Would send 'post published' email to #{user.email}"
  112. end
  113. end
  114. def send_comment_notification(comment)
  115. admin_email = get_setting('admin_email')
  116. return unless admin_email
  117. Rails.logger.info "Would send comment notification to #{admin_email}"
  118. end
  119. def send_author_notification(comment)
  120. return unless comment.commentable.respond_to?(:user)
  121. author = comment.commentable.user
  122. return unless author
  123. Rails.logger.info "Would send comment notification to post author: #{author.email}"
  124. end
  125. def get_recipients
  126. recipient_type = get_setting('post_notification_recipients', 'administrators')
  127. case recipient_type
  128. when 'administrators'
  129. User.administrator
  130. when 'editors'
  131. User.where(role: ['administrator', 'editor'])
  132. when 'all'
  133. User.all
  134. else
  135. User.administrator
  136. end
  137. end
  138. end
  139. # Auto-initialize if active
  140. if Plugin.exists?(name: 'Email Notifications', active: true)
  141. EmailNotifications.new
  142. end

lib/plugins/hello_tupac/hello_tupac.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class HelloTupac < Railspress::PluginBase
  2. plugin_name 'Hello Tupac!'
  3. plugin_version '1.0.0'
  4. plugin_description 'This is not just a plugin, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Tupac Shakur: Keep ya head up.'
  5. plugin_author 'RailsPress Team'
  6. # Tupac Shakur quotes
  7. TUPAC_QUOTES = [
  8. "Reality is wrong. Dreams are for real.",
  9. "Keep ya head up.",
  10. "Only God can judge me.",
  11. "I'm not perfect, but I'll always be real.",
  12. "For every dark night, there's a brighter day.",
  13. "They got money for war but can't feed the poor.",
  14. "The only thing that comes to a sleeping man is dreams.",
  15. "You gotta make a change.",
  16. "Behind every sweet smile is a bitter sadness.",
  17. "I'd rather die like a man than live like a coward.",
  18. "I'm a reflection of the community.",
  19. "I ain't mad at cha.",
  20. "Ain't nothin' like the old school.",
  21. "Even though you're fed up, keep your head up.",
  22. "I'm gonna spark the brain that changes the world.",
  23. "My only fear of death is coming back reincarnated.",
  24. "Trust nobody.",
  25. "It's just me against the world.",
  26. "Out on bail, fresh out of jail, California dreamin'.",
  27. "All eyez on me.",
  28. "Alcohol and booty calls.",
  29. "Cause life goes on.",
  30. "With G's in my pocket.",
  31. "Have a party at my funeral.",
  32. "Until I get free.",
  33. "I live my life in tha fast lane.",
  34. "Life goes on homie.",
  35. "Get money.",
  36. "Evade b*ches.",
  37. "Evade tricks."
  38. ].freeze unless defined?(TUPAC_QUOTES)
  39. def activate
  40. super
  41. Rails.logger.info "Hello Tupac! plugin activated - Keep ya head up!"
  42. # Register the admin sidebar hook
  43. register_admin_sidebar_hook
  44. end
  45. def deactivate
  46. super
  47. Rails.logger.info "Hello Tupac! plugin deactivated"
  48. end
  49. private
  50. def register_admin_sidebar_hook
  51. # Add content to the admin right topbar (next to Go to Site button)
  52. add_action('admin_right_topbar_content') do
  53. render_topbar_quote
  54. end
  55. end
  56. def render_topbar_quote
  57. quote = TUPAC_QUOTES.sample
  58. # Escape the quote for HTML attributes to prevent issues with quotes
  59. escaped_quote = quote.gsub('"', '&quot;').gsub("'", '&#39;')
  60. # Return HTML that will be rendered in the topbar
  61. "<div class='hello-tupac-quote inline-flex items-center gap-2 px-3 py-1 bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 rounded-lg text-xs text-purple-200 max-w-xs truncate cursor-help' title='#{escaped_quote} — Tupac Shakur' data-tooltip='#{escaped_quote} — Tupac Shakur'>
  62. <svg class='w-3 h-3 text-purple-400 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
  63. <path fill-rule='evenodd' d='M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z' clip-rule='evenodd'/>
  64. </svg>
  65. <span class='truncate'>#{quote}</span>
  66. </div>".html_safe
  67. end
  68. # Helper method to get a random quote
  69. def self.get_quote
  70. TUPAC_QUOTES.sample
  71. end
  72. end
  73. # Auto-initialize if active
  74. if Plugin.exists?(name: 'Hello Tupac!', active: true)
  75. HelloTupac.new
  76. end

lib/plugins/image_optimizer/image_optimizer.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Image Optimizer Plugin
  2. # Automatically optimizes uploaded images
  3. class ImageOptimizer < Railspress::PluginBase
  4. plugin_name 'Image Optimizer'
  5. plugin_version '1.0.0'
  6. plugin_description 'Automatically optimize images on upload for better performance'
  7. plugin_author 'RailsPress'
  8. def activate
  9. super
  10. register_hooks
  11. end
  12. private
  13. def register_hooks
  14. # Hook into file upload process
  15. add_action('media_uploaded', :optimize_image)
  16. end
  17. def optimize_image(medium)
  18. return unless medium.image?
  19. return unless medium.upload&.file&.attached?
  20. # Check if optimization is enabled in settings
  21. storage_config = StorageConfigurationService.new
  22. return unless storage_config.auto_optimize_enabled?
  23. # Check media settings
  24. return unless SiteSetting.get('auto_optimize_images', false)
  25. # Queue optimization job
  26. OptimizeImageJob.perform_later(medium_id: medium.id)
  27. Rails.logger.info "Queued image optimization for medium #{medium.id}"
  28. end
  29. end
  30. ImageOptimizer.new

lib/plugins/reading_time/reading_time.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Reading Time Plugin
  2. # Calculates estimated reading time for posts
  3. class ReadingTime < Railspress::PluginBase
  4. plugin_name 'Reading Time'
  5. plugin_version '1.0.0'
  6. plugin_description 'Displays estimated reading time for posts and pages'
  7. plugin_author 'RailsPress'
  8. WORDS_PER_MINUTE = 200
  9. def activate
  10. super
  11. inject_helper_methods
  12. end
  13. private
  14. def inject_helper_methods
  15. ApplicationController.helper_method :reading_time if defined?(ApplicationController)
  16. end
  17. # Calculate reading time for content
  18. def self.calculate(content)
  19. return 0 if content.blank?
  20. # Strip HTML tags and count words
  21. text = ActionView::Base.full_sanitizer.sanitize(content.to_s)
  22. word_count = text.split.size
  23. minutes = (word_count.to_f / WORDS_PER_MINUTE).ceil
  24. minutes < 1 ? 1 : minutes
  25. end
  26. # Format reading time
  27. def self.format(minutes)
  28. if minutes == 1
  29. "1 min read"
  30. else
  31. "#{minutes} min read"
  32. end
  33. end
  34. end
  35. # Helper module
  36. module ReadingTimeHelper
  37. def reading_time(content)
  38. minutes = ReadingTime.calculate(content)
  39. ReadingTime.format(minutes)
  40. end
  41. def reading_time_minutes(content)
  42. ReadingTime.calculate(content)
  43. end
  44. end
  45. # Include helper
  46. if defined?(ApplicationController)
  47. ApplicationController.helper(ReadingTimeHelper)
  48. end
  49. ReadingTime.new

lib/plugins/related_posts/related_posts.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Related Posts Plugin
  2. # Adds related posts functionality based on categories and tags
  3. class RelatedPosts < Railspress::PluginBase
  4. plugin_name 'Related Posts'
  5. plugin_version '1.0.0'
  6. plugin_description 'Displays related posts based on categories and tags'
  7. plugin_author 'RailsPress'
  8. # Define settings schema
  9. settings_schema do
  10. section 'General Settings' do
  11. number 'count', 'Number of related posts to show', default: 5, min: 1, max: 20
  12. checkbox 'show_excerpt', 'Show post excerpt', default: true
  13. checkbox 'show_thumbnail', 'Show post thumbnail', default: true
  14. select 'sort_by', 'Sort related posts by',
  15. options: [
  16. ['Relevance (default)', 'relevance'],
  17. ['Date (newest first)', 'date_desc'],
  18. ['Date (oldest first)', 'date_asc'],
  19. ['Title (A-Z)', 'title_asc'],
  20. ['Title (Z-A)', 'title_desc']
  21. ],
  22. default: 'relevance'
  23. end
  24. section 'Display Options' do
  25. text 'title', 'Section title', default: 'Related Posts', placeholder: 'e.g., You might also like...'
  26. checkbox 'show_in_single_post', 'Show in single post pages', default: true
  27. checkbox 'show_in_archive', 'Show in archive pages', default: false
  28. select 'layout', 'Display layout',
  29. options: [
  30. ['List (default)', 'list'],
  31. ['Grid (2 columns)', 'grid_2'],
  32. ['Grid (3 columns)', 'grid_3'],
  33. ['Grid (4 columns)', 'grid_4']
  34. ],
  35. default: 'list'
  36. end
  37. section 'Advanced' do
  38. checkbox 'include_same_author', 'Include posts by same author', default: false
  39. checkbox 'exclude_sticky', 'Exclude sticky posts', default: true
  40. number 'cache_duration', 'Cache duration (minutes)', default: 60, min: 0, max: 1440
  41. end
  42. end
  43. def activate
  44. super
  45. register_hooks
  46. inject_helper_methods
  47. end
  48. private
  49. def register_hooks
  50. # Add filter to modify related posts logic
  51. add_filter('related_posts_count', :get_related_posts_count)
  52. add_filter('related_posts_query', :enhance_related_posts_query)
  53. end
  54. def inject_helper_methods
  55. # Add helper method to ApplicationController
  56. ApplicationController.helper_method :get_related_posts if defined?(ApplicationController)
  57. end
  58. def get_related_posts_count(default_count)
  59. get_setting('count', default_count)
  60. end
  61. def enhance_related_posts_query(posts)
  62. # Can add additional filtering or sorting logic
  63. posts
  64. end
  65. # Public method that can be called from views
  66. def self.find_related(post, limit = 5)
  67. return Post.none unless post
  68. # Find posts with matching categories or tags
  69. related_by_category = Post.published
  70. .joins(:categories)
  71. .where(categories: { id: post.category_ids })
  72. .where.not(id: post.id)
  73. .distinct
  74. related_by_tag = Post.published
  75. .joins(:tags)
  76. .where(tags: { id: post.tag_ids })
  77. .where.not(id: post.id)
  78. .distinct
  79. # Combine and prioritize by category match
  80. (related_by_category.to_a + related_by_tag.to_a)
  81. .uniq
  82. .sort_by { |p| -matching_score(post, p) }
  83. .first(limit)
  84. end
  85. def self.matching_score(post1, post2)
  86. category_matches = (post1.category_ids & post2.category_ids).count * 2
  87. tag_matches = (post1.tag_ids & post2.tag_ids).count
  88. category_matches + tag_matches
  89. end
  90. end
  91. # Helper method for views
  92. module RelatedPostsHelper
  93. def get_related_posts(post, limit = 5)
  94. RelatedPosts.find_related(post, limit)
  95. end
  96. end
  97. # Include helper in ApplicationController
  98. if defined?(ApplicationController)
  99. ApplicationController.helper(RelatedPostsHelper)
  100. end
  101. RelatedPosts.new

lib/plugins/seo_optimizer_pro/seo_optimizer_pro.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SEO Optimizer Pro Plugin for RailsPress
  2. # Enhances SEO capabilities with sitemaps, meta tags, and analytics
  3. class SeoOptimizerPro < Railspress::PluginBase
  4. plugin_name 'SEO Optimizer Pro'
  5. plugin_version '2.5.0'
  6. plugin_description 'Complete SEO solution with XML sitemaps, meta tag management, and analytics'
  7. plugin_author 'RailsPress Team'
  8. def activate
  9. super
  10. Rails.logger.info "SEO Optimizer Pro activated"
  11. # Register hooks
  12. register_seo_hooks
  13. # Generate sitemap on activation
  14. GenerateSitemapJob.perform_later if defined?(GenerateSitemapJob)
  15. end
  16. def deactivate
  17. super
  18. Rails.logger.info "SEO Optimizer Pro deactivated"
  19. end
  20. private
  21. def register_seo_hooks
  22. # Add filter to modify page titles
  23. add_filter('page_title', :enhance_page_title)
  24. # Add action hook for tracking page views
  25. add_action('post_viewed', :track_page_view)
  26. end
  27. def enhance_page_title(title)
  28. site_name = SiteSetting.get('site_title', 'RailsPress')
  29. "#{title} | #{site_name}"
  30. end
  31. def track_page_view(post_id)
  32. # Track in analytics
  33. Rails.logger.info "Tracking view for post #{post_id}"
  34. end
  35. # Plugin-specific methods
  36. def generate_sitemap
  37. # Generate XML sitemap
  38. posts = Post.published
  39. pages = Page.published
  40. # Build sitemap XML
  41. # This would be implemented based on sitemap format
  42. end
  43. def get_google_analytics_id
  44. get_setting('google_analytics_id', '')
  45. end
  46. def sitemap_enabled?
  47. get_setting('sitemap_enabled', false)
  48. end
  49. end
  50. # Initialize the plugin
  51. SeoOptimizerPro.new

lib/plugins/sitemap_generator/sitemap_generator.rb

0.0% lines covered

100.0% branches covered

66 relevant lines. 0 lines covered and 66 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Sitemap Generator Plugin
  2. # Automatically generates XML sitemaps for SEO
  3. class SitemapGenerator < Railspress::PluginBase
  4. plugin_name 'Sitemap Generator'
  5. plugin_version '1.0.0'
  6. plugin_description 'Automatically generates XML sitemaps for better SEO'
  7. plugin_author 'RailsPress'
  8. def activate
  9. super
  10. register_hooks
  11. generate_sitemap
  12. end
  13. private
  14. def register_hooks
  15. # Generate sitemap when posts/pages are published
  16. add_action('post_published', :generate_sitemap)
  17. add_action('page_published', :generate_sitemap)
  18. end
  19. def generate_sitemap
  20. sitemap_content = build_sitemap_xml
  21. sitemap_path = Rails.public_path.join('sitemap.xml')
  22. File.write(sitemap_path, sitemap_content)
  23. Rails.logger.info "Sitemap generated at #{sitemap_path}"
  24. rescue => e
  25. Rails.logger.error "Failed to generate sitemap: #{e.message}"
  26. end
  27. def build_sitemap_xml
  28. xml = []
  29. xml << '<?xml version="1.0" encoding="UTF-8"?>'
  30. xml << '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
  31. # Add homepage
  32. xml << build_url_entry('/', priority: '1.0', changefreq: 'daily')
  33. # Add posts
  34. Post.published.find_each do |post|
  35. xml << build_url_entry(
  36. "/blog/#{post.slug}",
  37. lastmod: post.updated_at,
  38. priority: '0.8',
  39. changefreq: 'weekly'
  40. )
  41. end
  42. # Add pages
  43. Page.published.find_each do |page|
  44. xml << build_url_entry(
  45. "/#{page.slug}",
  46. lastmod: page.updated_at,
  47. priority: '0.6',
  48. changefreq: 'monthly'
  49. )
  50. end
  51. # Add category archives
  52. Term.for_taxonomy('category').find_each do |category|
  53. xml << build_url_entry(
  54. "/category/#{category.slug}",
  55. priority: '0.5',
  56. changefreq: 'weekly'
  57. )
  58. end
  59. xml << '</urlset>'
  60. xml.join("\n")
  61. end
  62. def build_url_entry(path, lastmod: nil, priority: '0.5', changefreq: 'monthly')
  63. base_url = get_setting('base_url', 'http://localhost:3000')
  64. entry = [" <url>"]
  65. entry << " <loc>#{base_url}#{path}</loc>"
  66. entry << " <lastmod>#{lastmod.strftime('%Y-%m-%d')}</lastmod>" if lastmod
  67. entry << " <changefreq>#{changefreq}</changefreq>"
  68. entry << " <priority>#{priority}</priority>"
  69. entry << " </url>"
  70. entry.join("\n")
  71. end
  72. end
  73. SitemapGenerator.new

lib/plugins/slick_forms/slick_forms.rb

0.0% lines covered

100.0% branches covered

471 relevant lines. 0 lines covered and 471 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms - Beautiful Form Builder for RailsPress
  2. #
  3. # Create and manage forms with drag-and-drop interface
  4. # Features:
  5. # - Form builder with basic fields (text, email, textarea, checkbox, select)
  6. # - Submission management
  7. # - Email notifications
  8. # - Anti-spam protection
  9. # - Export submissions
  10. class SlickForms < Railspress::PluginBase
  11. plugin_name 'Slick Forms'
  12. plugin_version '1.0.0'
  13. plugin_description 'Beautiful drag-and-drop form builder with submission management'
  14. plugin_author 'RailsPress'
  15. plugin_url 'https://railspress.com/plugins/slick-forms'
  16. plugin_license 'GPL-2.0'
  17. def setup
  18. # Settings
  19. define_setting :from_email,
  20. type: 'string',
  21. label: 'From Email',
  22. description: 'Email address for form notifications',
  23. default: 'noreply@example.com',
  24. required: true
  25. define_setting :enable_recaptcha,
  26. type: 'boolean',
  27. label: 'Enable reCAPTCHA',
  28. description: 'Protect forms from spam',
  29. default: false
  30. define_setting :recaptcha_site_key,
  31. type: 'string',
  32. label: 'reCAPTCHA Site Key',
  33. placeholder: '6Le...'
  34. define_setting :recaptcha_secret_key,
  35. type: 'string',
  36. label: 'reCAPTCHA Secret Key',
  37. placeholder: '6Le...'
  38. define_setting :store_submissions,
  39. type: 'boolean',
  40. label: 'Store Submissions',
  41. description: 'Save form submissions to database',
  42. default: true
  43. # Register admin pages
  44. register_admin_page(
  45. slug: 'forms',
  46. title: 'All Forms',
  47. menu_title: 'Forms',
  48. icon: 'document',
  49. callback: :render_forms_page
  50. )
  51. register_admin_page(
  52. slug: 'submissions',
  53. title: 'Form Submissions',
  54. menu_title: 'Submissions',
  55. icon: 'inbox',
  56. callback: :render_submissions_page
  57. )
  58. register_admin_page(
  59. slug: 'settings',
  60. title: 'Form Settings',
  61. menu_title: 'Settings',
  62. icon: 'cog'
  63. )
  64. # Register admin routes (automatically scoped under /admin)
  65. register_admin_routes do
  66. namespace :slick_forms do
  67. resources :forms do
  68. member do
  69. post :duplicate
  70. get :preview
  71. end
  72. collection do
  73. post :import
  74. end
  75. end
  76. resources :submissions, only: [:index, :show, :destroy] do
  77. collection do
  78. get :export
  79. post :bulk_action
  80. end
  81. end
  82. end
  83. end
  84. # Register frontend routes (automatically scoped under /plugins)
  85. register_frontend_routes do
  86. # Public form submission endpoint
  87. post 'submit/:form_id', to: 'slick_forms/submissions#create', as: 'slick_form_submit'
  88. # Public form display
  89. get 'form/:form_id', to: 'slick_forms/forms#show', as: 'slick_form_display'
  90. get 'form/:form_id/embed', to: 'slick_forms/forms#embed', as: 'slick_form_embed'
  91. end
  92. # ========================================
  93. # ENHANCED PLUGIN FEATURES DEMO
  94. # ========================================
  95. # Register webhooks for form events
  96. register_webhook('form.submitted', 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK', {
  97. method: 'POST',
  98. headers: { 'Content-Type' => 'application/json' },
  99. secret: 'your-webhook-secret'
  100. })
  101. register_webhook('form.spam_detected', 'https://api.example.com/spam-alert', {
  102. method: 'POST',
  103. retry_count: 5,
  104. timeout: 10
  105. })
  106. # Register event listeners
  107. on('user.registered') do |data|
  108. log("New user registered: #{data[:user][:email]}", :info)
  109. notify_admin("New user registration via form", :success, { user: data[:user] })
  110. end
  111. on('form.submission.failed') do |data|
  112. log("Form submission failed: #{data[:error]}", :error)
  113. notify_admin("Form submission failed", :error, {
  114. form_id: data[:form_id],
  115. error: data[:error]
  116. })
  117. end
  118. # Register assets
  119. register_stylesheet('slick_forms.css', { admin_only: true })
  120. register_javascript('slick_forms.js', { admin_only: true })
  121. register_stylesheet('slick_forms_frontend.css', { frontend_only: true })
  122. register_javascript('slick_forms_frontend.js', { frontend_only: true })
  123. # Register API endpoints
  124. register_api_endpoint('GET', 'forms', { controller: 'api/slick_forms/forms', action: 'index' }, {
  125. authentication: :token,
  126. rate_limit: 100
  127. })
  128. register_api_endpoint('POST', 'submissions', { controller: 'api/slick_forms/submissions', action: 'create' }, {
  129. authentication: :api_key,
  130. rate_limit: 50
  131. })
  132. # Register theme templates
  133. register_theme_template('contact_form', <<~LIQUID, { type: :page })
  134. <div class="slick-form-container">
  135. <h2>{{ form.title }}</h2>
  136. <form action="/plugins/slick_forms/submit/{{ form.id }}" method="post">
  137. {% for field in form.fields %}
  138. <div class="form-field">
  139. <label>{{ field.label }}</label>
  140. {% case field.type %}
  141. {% when 'text' %}
  142. <input type="text" name="{{ field.name }}" required="{{ field.required }}">
  143. {% when 'email' %}
  144. <input type="email" name="{{ field.name }}" required="{{ field.required }}">
  145. {% when 'textarea' %}
  146. <textarea name="{{ field.name }}" required="{{ field.required }}"></textarea>
  147. {% endcase %}
  148. </div>
  149. {% endfor %}
  150. <button type="submit">Submit</button>
  151. </form>
  152. </div>
  153. LIQUID
  154. # Register theme settings
  155. register_theme_setting('form_style', :select, {
  156. label: 'Form Style',
  157. description: 'Choose the form styling',
  158. default: 'modern',
  159. options: { 'modern' => 'Modern', 'classic' => 'Classic', 'minimal' => 'Minimal' }
  160. })
  161. register_theme_setting('show_labels', :boolean, {
  162. label: 'Show Field Labels',
  163. description: 'Display field labels above inputs',
  164. default: true
  165. })
  166. # Register custom validators
  167. register_validator('email_domain') do |email|
  168. allowed_domains = get_setting(:allowed_email_domains, []).split(',')
  169. return true if allowed_domains.empty?
  170. domain = email.split('@').last
  171. allowed_domains.include?(domain.strip)
  172. end
  173. register_validator('strong_password') do |password|
  174. return false if password.length < 8
  175. return false unless password.match?(/[A-Z]/) # Uppercase
  176. return false unless password.match?(/[a-z]/) # Lowercase
  177. return false unless password.match?(/\d/) # Number
  178. return false unless password.match?(/[^A-Za-z0-9]/) # Special char
  179. true
  180. end
  181. # Register custom commands
  182. register_command('cleanup', 'Clean up old form submissions') do
  183. cutoff_date = 6.months.ago
  184. deleted_count = SlickFormSubmission.where('created_at < ?', cutoff_date).delete_all
  185. puts "Cleaned up #{deleted_count} old submissions"
  186. end
  187. register_command('stats', 'Show form statistics') do
  188. total_forms = SlickForm.count
  189. total_submissions = SlickFormSubmission.count
  190. spam_count = SlickFormSubmission.where(spam: true).count
  191. puts "=== SlickForms Statistics ==="
  192. puts "Total Forms: #{total_forms}"
  193. puts "Total Submissions: #{total_submissions}"
  194. puts "Spam Submissions: #{spam_count}"
  195. puts "Legitimate Submissions: #{total_submissions - spam_count}"
  196. end
  197. # Schedule recurring tasks
  198. schedule_task('cleanup_spam', '0 2 * * *') do
  199. # Clean up spam submissions older than 30 days
  200. cutoff_date = 30.days.ago
  201. spam_count = SlickFormSubmission.where(spam: true, created_at: ...cutoff_date).delete_all
  202. log("Cleaned up #{spam_count} old spam submissions", :info)
  203. end
  204. schedule_task('generate_reports', '0 8 * * 1') do
  205. # Generate weekly reports
  206. week_start = 1.week.ago.beginning_of_day
  207. week_end = Time.current.end_of_day
  208. submissions = SlickFormSubmission.where(created_at: week_start..week_end)
  209. forms_used = submissions.distinct.count(:slick_form_id)
  210. notify_admin("Weekly Form Report: #{submissions.count} submissions across #{forms_used} forms", :info, {
  211. period: 'weekly',
  212. submissions: submissions.count,
  213. forms: forms_used
  214. })
  215. end
  216. # Background job for email notifications
  217. create_job 'NotificationJob' do
  218. def perform(submission_id)
  219. submission = find_submission(submission_id)
  220. return unless submission
  221. # Send email notification
  222. SlickFormsMailer.new_submission(submission).deliver_later
  223. Rails.logger.info "Sent notification for submission ##{submission_id}"
  224. end
  225. private
  226. def find_submission(id)
  227. # Implementation would fetch from database
  228. nil
  229. end
  230. end
  231. # Hooks
  232. add_action('form_submitted', :process_submission)
  233. add_filter('form_fields', :add_honeypot_field)
  234. log("SlickForms initialized successfully")
  235. end
  236. def activate
  237. super
  238. create_forms_table
  239. create_submissions_table
  240. log("SlickForms activated and tables created")
  241. end
  242. def deactivate
  243. super
  244. log("SlickForms deactivated")
  245. end
  246. def uninstall
  247. super
  248. drop_tables if get_setting(:delete_data_on_uninstall, false)
  249. log("SlickForms uninstalled")
  250. end
  251. # Render forms management page
  252. def render_forms_page
  253. {
  254. title: 'All Forms',
  255. forms: get_all_forms,
  256. stats: {
  257. total_forms: get_all_forms.size,
  258. total_submissions: get_submission_count,
  259. active_forms: get_all_forms.count { |f| f[:active] }
  260. }
  261. }
  262. end
  263. # Render submissions page
  264. def render_submissions_page
  265. {
  266. title: 'Form Submissions',
  267. submissions: get_recent_submissions(50),
  268. stats: {
  269. total: get_submission_count,
  270. today: get_submissions_today_count,
  271. this_week: get_submissions_week_count
  272. }
  273. }
  274. end
  275. # Get plugin metadata
  276. def metadata
  277. {
  278. name: name,
  279. version: version,
  280. description: description,
  281. author: author,
  282. supported_fields: supported_fields
  283. }
  284. end
  285. # Supported field types (Free version)
  286. def supported_fields
  287. [
  288. { type: 'text', label: 'Text Field', icon: '📝' },
  289. { type: 'email', label: 'Email Field', icon: '📧' },
  290. { type: 'textarea', label: 'Textarea', icon: '📄' },
  291. { type: 'select', label: 'Dropdown', icon: '📋' },
  292. { type: 'checkbox', label: 'Checkbox', icon: '☑️' },
  293. { type: 'radio', label: 'Radio Buttons', icon: '🔘' },
  294. { type: 'number', label: 'Number Field', icon: '🔢' },
  295. { type: 'url', label: 'URL Field', icon: '🔗' }
  296. ]
  297. end
  298. # Free version features
  299. def features
  300. {
  301. 'Drag and Drop Builder' => true,
  302. 'AI Form Builder' => true,
  303. 'Conditional Logic' => true,
  304. 'Advanced Form Styler' => false,
  305. 'Numeric Calculation' => false,
  306. 'Unique Entry Validation' => true,
  307. 'Multi-Step Forms' => false,
  308. 'Conversational Forms' => true,
  309. 'Advanced Post Creation' => false,
  310. 'Payment' => true,
  311. 'Coupon' => false,
  312. 'Inventory' => false,
  313. 'Address Autocomplete' => false,
  314. 'Spam Protection' => true,
  315. 'Quiz and Survey' => false,
  316. 'Multi-column Form' => true,
  317. 'Version History' => true,
  318. 'Fully Responsive' => true,
  319. 'Personality Quiz' => false,
  320. 'CSS Ready Classes' => true,
  321. 'Keyboard Navigation' => true,
  322. 'Undo/Redo' => true,
  323. 'Default Input Fields Value' => true,
  324. 'Accessibility' => true
  325. }
  326. end
  327. # Implement Free Features
  328. def drag_drop_builder_enabled?
  329. true # Always available in free
  330. end
  331. def ai_form_builder_enabled?
  332. true # Basic AI form generation
  333. end
  334. def conditional_logic_enabled?
  335. true # Basic show/hide fields
  336. end
  337. def unique_entry_validation_enabled?
  338. true # Email uniqueness, etc.
  339. end
  340. def conversational_forms_enabled?
  341. true # Basic conversational flow
  342. end
  343. def payment_enabled?
  344. true # Basic payment processing
  345. end
  346. def spam_protection_enabled?
  347. true # Honeypot, basic validation
  348. end
  349. def multi_column_forms_enabled?
  350. true # 2-column layouts
  351. end
  352. def version_history_enabled?
  353. true # Basic form versioning
  354. end
  355. def fully_responsive_enabled?
  356. true # Mobile-friendly forms
  357. end
  358. def css_ready_classes_enabled?
  359. true # CSS classes for styling
  360. end
  361. def keyboard_navigation_enabled?
  362. true # Tab navigation
  363. end
  364. def undo_redo_enabled?
  365. true # Basic undo/redo in builder
  366. end
  367. def default_input_values_enabled?
  368. true # Default field values
  369. end
  370. def accessibility_enabled?
  371. true # ARIA labels, screen reader support
  372. end
  373. # Pro Features (disabled in free)
  374. def advanced_form_styler_enabled?
  375. false
  376. end
  377. def numeric_calculation_enabled?
  378. false
  379. end
  380. def multi_step_forms_enabled?
  381. false
  382. end
  383. def advanced_post_creation_enabled?
  384. false
  385. end
  386. def coupon_enabled?
  387. false
  388. end
  389. def inventory_enabled?
  390. false
  391. end
  392. def address_autocomplete_enabled?
  393. false
  394. end
  395. def quiz_survey_enabled?
  396. false
  397. end
  398. def personality_quiz_enabled?
  399. false
  400. end
  401. private
  402. def process_submission(form_id, data)
  403. log("Processing submission for form #{form_id}")
  404. # Apply spam protection
  405. if spam_protection_enabled?
  406. return false if detect_spam(data)
  407. end
  408. # Apply unique entry validation
  409. if unique_entry_validation_enabled?
  410. return false unless validate_unique_entries(data)
  411. end
  412. # Process payment if enabled
  413. if payment_enabled? && data[:payment_required]
  414. process_payment(data)
  415. end
  416. # Save submission
  417. save_submission(form_id, data)
  418. log("Submission processed successfully for form #{form_id}")
  419. true
  420. end
  421. def detect_spam(data)
  422. # Check honeypot field
  423. return true if data[:website].present?
  424. # Basic spam detection
  425. spam_keywords = ['viagra', 'casino', 'loan', 'free money']
  426. content = data.values.join(' ').downcase
  427. spam_keywords.any? { |keyword| content.include?(keyword) }
  428. end
  429. def validate_unique_entries(data)
  430. # Check for unique email addresses
  431. if data[:email].present?
  432. existing = get_submissions_by_email(data[:email])
  433. return false if existing.any?
  434. end
  435. true
  436. end
  437. def process_payment(data)
  438. log("Processing payment for submission")
  439. # Basic payment processing logic
  440. end
  441. def save_submission(form_id, data)
  442. log("Saving submission for form #{form_id}")
  443. # Save to database
  444. end
  445. def get_submissions_by_email(email)
  446. return [] unless table_exists?('slick_form_submissions')
  447. ActiveRecord::Base.connection.execute(
  448. "SELECT * FROM slick_form_submissions WHERE JSON_EXTRACT(data, '$.email') = '#{email}'"
  449. ).to_a
  450. end
  451. def add_honeypot_field(fields, form)
  452. # Add honeypot field for spam protection
  453. fields + [{ type: 'text', name: 'website', label: 'Website', hidden: true }]
  454. end
  455. def create_forms_table
  456. return if table_exists?('slick_forms')
  457. ActiveRecord::Migration.create_table :slick_forms do |t|
  458. t.string :name, null: false
  459. t.string :title
  460. t.text :description
  461. t.json :fields, default: []
  462. t.json :settings, default: {}
  463. t.boolean :active, default: true
  464. t.integer :submissions_count, default: 0
  465. t.integer :tenant_id
  466. t.timestamps
  467. end
  468. log("Created slick_forms table")
  469. end
  470. def create_submissions_table
  471. return if table_exists?('slick_form_submissions')
  472. ActiveRecord::Migration.create_table :slick_form_submissions do |t|
  473. t.references :slick_form, null: false
  474. t.json :data
  475. t.string :ip_address
  476. t.string :user_agent
  477. t.string :referrer
  478. t.boolean :spam, default: false
  479. t.integer :tenant_id
  480. t.timestamps
  481. end
  482. log("Created slick_form_submissions table")
  483. end
  484. def drop_tables
  485. ActiveRecord::Migration.drop_table :slick_form_submissions if table_exists?('slick_form_submissions')
  486. ActiveRecord::Migration.drop_table :slick_forms if table_exists?('slick_forms')
  487. log("Dropped SlickForms tables")
  488. end
  489. def table_exists?(table_name)
  490. ActiveRecord::Base.connection.table_exists?(table_name)
  491. end
  492. def get_all_forms
  493. return [] unless table_exists?('slick_forms')
  494. ActiveRecord::Base.connection.execute("SELECT * FROM slick_forms").to_a.map(&:symbolize_keys)
  495. end
  496. def get_submission_count
  497. return 0 unless table_exists?('slick_form_submissions')
  498. ActiveRecord::Base.connection.execute("SELECT COUNT(*) as count FROM slick_form_submissions WHERE spam = 0").first['count']
  499. end
  500. def get_recent_submissions(limit = 50)
  501. return [] unless table_exists?('slick_form_submissions')
  502. ActiveRecord::Base.connection.execute(
  503. "SELECT * FROM slick_form_submissions WHERE spam = 0 ORDER BY created_at DESC LIMIT #{limit}"
  504. ).to_a.map(&:symbolize_keys)
  505. end
  506. def get_submissions_today_count
  507. return 0 unless table_exists?('slick_form_submissions')
  508. today = Date.today.to_s
  509. ActiveRecord::Base.connection.execute(
  510. "SELECT COUNT(*) as count FROM slick_form_submissions WHERE DATE(created_at) = '#{today}' AND spam = 0"
  511. ).first['count']
  512. end
  513. def get_submissions_week_count
  514. return 0 unless table_exists?('slick_form_submissions')
  515. week_ago = 7.days.ago.to_s
  516. ActiveRecord::Base.connection.execute(
  517. "SELECT COUNT(*) as count FROM slick_form_submissions WHERE created_at >= '#{week_ago}' AND spam = 0"
  518. ).first['count']
  519. end
  520. end
  521. # Register the plugin
  522. Railspress::PluginSystem.register_plugin('slick_forms', SlickForms.new)

lib/plugins/slick_forms_pro/slick_forms_pro.rb

0.0% lines covered

100.0% branches covered

397 relevant lines. 0 lines covered and 397 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # SlickForms Pro - Advanced Form Builder
  2. #
  3. # Extends SlickForms with premium features:
  4. # - Advanced field types (file upload, date picker, rating, signature)
  5. # - Conditional logic
  6. # - Multi-page forms
  7. # - Payment integration (Stripe)
  8. # - Advanced analytics
  9. # - White labeling
  10. # - Priority support
  11. class SlickFormsPro < Railspress::PluginBase
  12. plugin_name 'Slick Forms Pro'
  13. plugin_version '2.0.0'
  14. plugin_description 'Advanced form builder with premium features, payments, and analytics'
  15. plugin_author 'RailsPress Pro'
  16. plugin_url 'https://railspress.com/plugins/slick-forms-pro'
  17. plugin_license 'Commercial'
  18. def setup
  19. # Check if base SlickForms is active
  20. unless plugin_active?('slick_forms')
  21. log("WARNING: SlickForms Pro requires SlickForms base plugin", :warn)
  22. end
  23. # Additional Pro Settings
  24. define_setting :enable_payments,
  25. type: 'boolean',
  26. label: 'Enable Payment Forms',
  27. description: 'Allow forms to accept payments via Stripe',
  28. default: false
  29. define_setting :stripe_publishable_key,
  30. type: 'string',
  31. label: 'Stripe Publishable Key',
  32. placeholder: 'pk_...'
  33. define_setting :stripe_secret_key,
  34. type: 'string',
  35. label: 'Stripe Secret Key',
  36. placeholder: 'sk_...'
  37. define_setting :enable_analytics,
  38. type: 'boolean',
  39. label: 'Enable Analytics',
  40. description: 'Track form views, submissions, and conversion rates',
  41. default: true
  42. define_setting :enable_conditional_logic,
  43. type: 'boolean',
  44. label: 'Enable Conditional Logic',
  45. description: 'Show/hide fields based on other field values',
  46. default: true
  47. define_setting :max_file_size,
  48. type: 'number',
  49. label: 'Max File Size (MB)',
  50. default: 10,
  51. min: 1,
  52. max: 100
  53. define_setting :allowed_file_types,
  54. type: 'text',
  55. label: 'Allowed File Types',
  56. description: 'Comma-separated list of allowed extensions',
  57. default: 'pdf,doc,docx,jpg,png'
  58. # Register admin pages
  59. register_admin_page(
  60. slug: 'analytics',
  61. title: 'Form Analytics',
  62. menu_title: 'Analytics',
  63. icon: 'chart',
  64. callback: :render_analytics_page
  65. )
  66. register_admin_page(
  67. slug: 'payments',
  68. title: 'Payment Forms',
  69. menu_title: 'Payments',
  70. icon: 'credit-card',
  71. callback: :render_payments_page
  72. )
  73. register_admin_page(
  74. slug: 'integrations',
  75. title: 'Integrations',
  76. menu_title: 'Integrations',
  77. icon: 'link',
  78. callback: :render_integrations_page
  79. )
  80. register_admin_page(
  81. slug: 'settings',
  82. title: 'Pro Settings',
  83. menu_title: 'Pro Settings',
  84. icon: 'cog'
  85. )
  86. # Register admin routes (automatically scoped under /admin)
  87. register_admin_routes do
  88. namespace :slick_forms_pro do
  89. # Analytics
  90. get 'analytics/overview', to: 'analytics#overview'
  91. get 'analytics/form/:id', to: 'analytics#form'
  92. get 'analytics/export', to: 'analytics#export'
  93. # Payments
  94. resources :payments, only: [:index, :show] do
  95. member do
  96. post :refund
  97. end
  98. end
  99. # Integrations
  100. resources :integrations do
  101. member do
  102. post :test
  103. patch :toggle
  104. end
  105. end
  106. # Templates
  107. resources :templates, only: [:index, :show, :create]
  108. end
  109. end
  110. # Register frontend routes (automatically scoped under /plugins)
  111. register_frontend_routes do
  112. # File upload endpoint
  113. post 'upload', to: 'slick_forms_pro/uploads#create', as: 'slick_form_pro_upload'
  114. # Payment webhook
  115. post 'webhooks/stripe', to: 'slick_forms_pro/webhooks#stripe'
  116. # API endpoints
  117. namespace :api do
  118. namespace :v1 do
  119. get 'forms/:id/stats', to: 'slick_forms_pro/stats#show'
  120. post 'forms/:id/validate', to: 'slick_forms_pro/validator#validate'
  121. end
  122. end
  123. end
  124. # Create background jobs
  125. create_job 'SlickFormsProAnalyticsJob' do
  126. def perform
  127. # Process analytics data
  128. Rails.logger.info "Processing SlickForms Pro analytics"
  129. end
  130. end
  131. create_job 'SlickFormsProPaymentJob' do
  132. def perform(payment_id)
  133. # Process payment
  134. Rails.logger.info "Processing payment ##{payment_id}"
  135. end
  136. end
  137. # Schedule recurring analytics job
  138. schedule_recurring_job(
  139. 'analytics_daily',
  140. 'SlickFormsProAnalyticsJob',
  141. cron: '0 2 * * *' # Daily at 2 AM
  142. )
  143. # Add filters to extend base plugin
  144. add_filter('slick_forms_field_types', :add_pro_fields)
  145. add_filter('slick_forms_form_settings', :add_pro_settings)
  146. add_action('slick_forms_submission_saved', :process_pro_features)
  147. log("SlickForms Pro initialized successfully")
  148. end
  149. def activate
  150. super
  151. create_pro_tables
  152. set_setting(:enable_analytics, true)
  153. set_setting(:max_file_size, 10)
  154. log("SlickForms Pro activated")
  155. end
  156. # Render analytics page
  157. def render_analytics_page
  158. {
  159. title: 'Form Analytics',
  160. charts: generate_analytics_charts,
  161. stats: {
  162. total_views: get_total_views,
  163. total_submissions: get_total_submissions,
  164. conversion_rate: calculate_conversion_rate,
  165. average_time: calculate_average_completion_time
  166. },
  167. top_forms: get_top_performing_forms(5)
  168. }
  169. end
  170. # Render payments page
  171. def render_payments_page
  172. {
  173. title: 'Payment Forms',
  174. payments: get_recent_payments(50),
  175. stats: {
  176. total_revenue: calculate_total_revenue,
  177. successful_payments: get_successful_payments_count,
  178. failed_payments: get_failed_payments_count,
  179. refunded_amount: calculate_refunded_amount
  180. }
  181. }
  182. end
  183. # Render integrations page
  184. def render_integrations_page
  185. {
  186. title: 'Integrations',
  187. available_integrations: available_integrations,
  188. active_integrations: get_active_integrations
  189. }
  190. end
  191. # Available integrations
  192. def available_integrations
  193. [
  194. { id: 'mailchimp', name: 'Mailchimp', icon: '📧', description: 'Add subscribers to Mailchimp lists' },
  195. { id: 'slack', name: 'Slack', icon: '💬', description: 'Send notifications to Slack channels' },
  196. { id: 'zapier', name: 'Zapier', icon: '⚡', description: 'Connect to 3000+ apps via Zapier' },
  197. { id: 'google_sheets', name: 'Google Sheets', icon: '📊', description: 'Save submissions to Google Sheets' },
  198. { id: 'webhooks', name: 'Custom Webhooks', icon: '🔗', description: 'Send data to custom webhook URLs' }
  199. ]
  200. end
  201. private
  202. def plugin_active?(plugin_identifier)
  203. Railspress::PluginSystem.plugin_loaded?(plugin_identifier)
  204. end
  205. def add_pro_fields(base_fields, form)
  206. base_fields + [
  207. { type: 'file', label: 'File Upload', icon: '📎' },
  208. { type: 'date', label: 'Date Picker', icon: '📅' },
  209. { type: 'time', label: 'Time Picker', icon: '⏰' },
  210. { type: 'rating', label: 'Star Rating', icon: '⭐' },
  211. { type: 'signature', label: 'Signature Pad', icon: '✍️' },
  212. { type: 'phone', label: 'Phone Number', icon: '📱' },
  213. { type: 'address', label: 'Address Field', icon: '🏠' },
  214. { type: 'payment', label: 'Payment Field', icon: '💳' },
  215. { type: 'slider', label: 'Range Slider', icon: '🎚️' },
  216. { type: 'color', label: 'Color Picker', icon: '🎨' },
  217. { type: 'matrix', label: 'Matrix Rating', icon: '📊' },
  218. { type: 'survey', label: 'Survey Field', icon: '📋' }
  219. ]
  220. end
  221. # Pro version features (extends free features)
  222. def features
  223. {
  224. 'Drag and Drop Builder' => true,
  225. 'AI Form Builder' => true,
  226. 'Conditional Logic' => true,
  227. 'Advanced Form Styler' => true,
  228. 'Numeric Calculation' => true,
  229. 'Unique Entry Validation' => true,
  230. 'Multi-Step Forms' => true,
  231. 'Conversational Forms' => true,
  232. 'Advanced Post Creation' => true,
  233. 'Payment' => true,
  234. 'Coupon' => true,
  235. 'Inventory' => true,
  236. 'Address Autocomplete' => true,
  237. 'Spam Protection' => true,
  238. 'Quiz and Survey' => true,
  239. 'Multi-column Form' => true,
  240. 'Version History' => true,
  241. 'Fully Responsive' => true,
  242. 'Personality Quiz' => true,
  243. 'CSS Ready Classes' => true,
  244. 'Keyboard Navigation' => true,
  245. 'Undo/Redo' => true,
  246. 'Default Input Fields Value' => true,
  247. 'Accessibility' => true
  248. }
  249. end
  250. # Implement Pro Features
  251. def advanced_form_styler_enabled?
  252. true # Advanced CSS customization, themes
  253. end
  254. def numeric_calculation_enabled?
  255. true # Mathematical operations between fields
  256. end
  257. def multi_step_forms_enabled?
  258. true # Wizard-style multi-page forms
  259. end
  260. def advanced_post_creation_enabled?
  261. true # Auto-create posts from form submissions
  262. end
  263. def coupon_enabled?
  264. true # Discount codes, coupons
  265. end
  266. def inventory_enabled?
  267. true # Stock management, product selection
  268. end
  269. def address_autocomplete_enabled?
  270. true # Google Places API integration
  271. end
  272. def quiz_survey_enabled?
  273. true # Scoring, results, analytics
  274. end
  275. def personality_quiz_enabled?
  276. true # Advanced quiz logic, personality tests
  277. end
  278. # Advanced field types
  279. def advanced_field_types
  280. [
  281. { type: 'calculation', label: 'Calculation Field', icon: '🧮' },
  282. { type: 'signature', label: 'Signature Pad', icon: '✍️' },
  283. { type: 'file_upload', label: 'File Upload', icon: '📎' },
  284. { type: 'date_time', label: 'Date & Time', icon: '📅' },
  285. { type: 'rating', label: 'Rating Scale', icon: '⭐' },
  286. { type: 'matrix', label: 'Matrix Rating', icon: '📊' },
  287. { type: 'slider', label: 'Range Slider', icon: '🎚️' },
  288. { type: 'color_picker', label: 'Color Picker', icon: '🎨' },
  289. { type: 'address', label: 'Address with Autocomplete', icon: '🏠' },
  290. { type: 'product_selector', label: 'Product Selector', icon: '🛒' },
  291. { type: 'coupon_field', label: 'Coupon Code', icon: '🎫' },
  292. { type: 'inventory_tracker', label: 'Inventory Tracker', icon: '📦' }
  293. ]
  294. end
  295. # Process advanced features
  296. def process_pro_features(submission)
  297. # Process calculations
  298. if numeric_calculation_enabled?
  299. process_calculations(submission)
  300. end
  301. # Process inventory
  302. if inventory_enabled?
  303. update_inventory(submission)
  304. end
  305. # Process coupons
  306. if coupon_enabled?
  307. validate_coupon(submission)
  308. end
  309. # Process quiz scoring
  310. if quiz_survey_enabled?
  311. calculate_quiz_score(submission)
  312. end
  313. # Process address autocomplete
  314. if address_autocomplete_enabled?
  315. validate_address(submission)
  316. end
  317. # Process advanced post creation
  318. if advanced_post_creation_enabled?
  319. create_advanced_post(submission)
  320. end
  321. end
  322. private
  323. def process_calculations(submission)
  324. log("Processing numeric calculations")
  325. # Calculate totals, tax, shipping, etc.
  326. end
  327. def update_inventory(submission)
  328. log("Updating inventory levels")
  329. # Decrease stock for purchased items
  330. end
  331. def validate_coupon(submission)
  332. log("Validating coupon code")
  333. # Check coupon validity and apply discount
  334. end
  335. def calculate_quiz_score(submission)
  336. log("Calculating quiz score")
  337. # Score quiz responses and determine results
  338. end
  339. def validate_address(submission)
  340. log("Validating and formatting address")
  341. # Use Google Places API to validate addresses
  342. end
  343. def create_advanced_post(submission)
  344. log("Creating advanced post from submission")
  345. # Auto-generate posts with custom fields, categories, etc.
  346. end
  347. def add_pro_settings(base_settings, form)
  348. base_settings.merge({
  349. conditional_logic: true,
  350. multi_page: true,
  351. save_progress: true,
  352. payment_enabled: get_setting(:enable_payments, false)
  353. })
  354. end
  355. def process_pro_features(submission)
  356. # Process analytics
  357. track_submission_analytics(submission) if get_setting(:enable_analytics, true)
  358. # Process payment if applicable
  359. process_payment(submission) if submission[:payment_required]
  360. # Send to integrations
  361. send_to_integrations(submission)
  362. end
  363. def create_pro_tables
  364. # Analytics table
  365. unless table_exists?('slick_forms_analytics')
  366. ActiveRecord::Migration.create_table :slick_forms_analytics do |t|
  367. t.references :slick_form, null: false
  368. t.date :date, null: false
  369. t.integer :views, default: 0
  370. t.integer :submissions, default: 0
  371. t.integer :spam_blocked, default: 0
  372. t.decimal :conversion_rate, precision: 5, scale: 2
  373. t.decimal :avg_completion_time, precision: 10, scale: 2
  374. t.timestamps
  375. end
  376. end
  377. # Payments table
  378. unless table_exists?('slick_forms_payments')
  379. ActiveRecord::Migration.create_table :slick_forms_payments do |t|
  380. t.references :slick_form_submission, null: false
  381. t.string :stripe_payment_intent_id
  382. t.decimal :amount, precision: 10, scale: 2, null: false
  383. t.string :currency, default: 'usd'
  384. t.string :status # pending, succeeded, failed, refunded
  385. t.text :error_message
  386. t.datetime :paid_at
  387. t.datetime :refunded_at
  388. t.timestamps
  389. end
  390. end
  391. # Integrations table
  392. unless table_exists?('slick_forms_integrations')
  393. ActiveRecord::Migration.create_table :slick_forms_integrations do |t|
  394. t.references :slick_form, null: false
  395. t.string :integration_type # mailchimp, slack, zapier, etc.
  396. t.string :name
  397. t.json :config, default: {}
  398. t.boolean :active, default: true
  399. t.timestamps
  400. end
  401. end
  402. log("Created SlickForms Pro tables")
  403. end
  404. def track_submission_analytics(submission)
  405. # Analytics tracking logic
  406. end
  407. def process_payment(submission)
  408. # Payment processing logic
  409. end
  410. def send_to_integrations(submission)
  411. # Integration sending logic
  412. end
  413. def generate_analytics_charts
  414. # Chart data generation
  415. []
  416. end
  417. def get_total_views
  418. 0
  419. end
  420. def get_total_submissions
  421. 0
  422. end
  423. def calculate_conversion_rate
  424. 0.0
  425. end
  426. def calculate_average_completion_time
  427. 0.0
  428. end
  429. def get_top_performing_forms(limit)
  430. []
  431. end
  432. def get_recent_payments(limit)
  433. []
  434. end
  435. def calculate_total_revenue
  436. 0.0
  437. end
  438. def get_successful_payments_count
  439. 0
  440. end
  441. def get_failed_payments_count
  442. 0
  443. end
  444. def calculate_refunded_amount
  445. 0.0
  446. end
  447. def get_active_integrations
  448. []
  449. end
  450. def table_exists?(table_name)
  451. ActiveRecord::Base.connection.table_exists?(table_name)
  452. end
  453. end
  454. # Register the plugin
  455. Railspress::PluginSystem.register_plugin('slick_forms_pro', SlickFormsPro.new)

lib/plugins/social_sharing/social_sharing.rb

0.0% lines covered

100.0% branches covered

149 relevant lines. 0 lines covered and 149 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Social Sharing Plugin
  2. # Adds social media sharing buttons to posts and pages
  3. class SocialSharing < Railspress::PluginBase
  4. plugin_name 'Social Sharing'
  5. plugin_version '1.0.0'
  6. plugin_description 'Add beautiful social sharing buttons to your content'
  7. plugin_author 'RailsPress'
  8. def activate
  9. super
  10. inject_helper_methods
  11. end
  12. private
  13. def inject_helper_methods
  14. ApplicationController.helper_method :social_share_buttons if defined?(ApplicationController)
  15. end
  16. # Generate Open Graph meta tags
  17. def self.open_graph_tags(post)
  18. return '' unless post
  19. tags = []
  20. tags << tag(:meta, property: 'og:title', content: post.title)
  21. tags << tag(:meta, property: 'og:type', content: 'article')
  22. tags << tag(:meta, property: 'og:url', content: post_url(post))
  23. tags << tag(:meta, property: 'og:description', content: post.excerpt || post.title)
  24. if post.featured_image_file.attached?
  25. tags << tag(:meta, property: 'og:image', content: url_for(post.featured_image_file))
  26. end
  27. tags << tag(:meta, property: 'article:published_time', content: post.published_at.iso8601)
  28. tags << tag(:meta, property: 'article:author', content: post.author_name)
  29. tags.join("\n").html_safe
  30. end
  31. # Generate Twitter Card meta tags
  32. def self.twitter_card_tags(post)
  33. return '' unless post
  34. tags = []
  35. tags << tag(:meta, name: 'twitter:card', content: 'summary_large_image')
  36. tags << tag(:meta, name: 'twitter:title', content: post.title)
  37. tags << tag(:meta, name: 'twitter:description', content: post.excerpt || post.title)
  38. if post.featured_image_file.attached?
  39. tags << tag(:meta, name: 'twitter:image', content: url_for(post.featured_image_file))
  40. end
  41. tags.join("\n").html_safe
  42. end
  43. def self.tag(name, attributes = {})
  44. attrs = attributes.map { |k, v| "#{k}=\"#{ERB::Util.html_escape(v)}\"" }.join(' ')
  45. "<#{name} #{attrs}>"
  46. end
  47. def self.post_url(post)
  48. # This would use the actual URL helper
  49. "http://localhost:3000/blog/#{post.slug}"
  50. end
  51. def self.url_for(attachment)
  52. Rails.application.routes.url_helpers.rails_blob_url(attachment, only_path: false)
  53. rescue
  54. ''
  55. end
  56. end
  57. # Helper module
  58. module SocialSharingHelper
  59. def social_share_buttons(post, options = {})
  60. platforms = options[:platforms] || [:facebook, :twitter, :linkedin, :pinterest, :email]
  61. size = options[:size] || 'medium'
  62. url = blog_post_url(post.slug)
  63. title = post.title
  64. buttons = platforms.map do |platform|
  65. case platform
  66. when :facebook
  67. link_to "https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(url)}",
  68. target: '_blank',
  69. class: "share-button share-facebook #{size}",
  70. title: "Share on Facebook" do
  71. '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'.html_safe
  72. end
  73. when :twitter
  74. link_to "https://twitter.com/intent/tweet?url=#{CGI.escape(url)}&text=#{CGI.escape(title)}",
  75. target: '_blank',
  76. class: "share-button share-twitter #{size}",
  77. title: "Share on Twitter" do
  78. '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'.html_safe
  79. end
  80. when :linkedin
  81. link_to "https://www.linkedin.com/shareArticle?mini=true&url=#{CGI.escape(url)}&title=#{CGI.escape(title)}",
  82. target: '_blank',
  83. class: "share-button share-linkedin #{size}",
  84. title: "Share on LinkedIn" do
  85. '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'.html_safe
  86. end
  87. when :email
  88. link_to "mailto:?subject=#{CGI.escape(title)}&body=#{CGI.escape(url)}",
  89. class: "share-button share-email #{size}",
  90. title: "Share via Email" do
  91. '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>'.html_safe
  92. end
  93. end
  94. end
  95. content_tag :div, class: 'social-share-buttons flex items-center space-x-2' do
  96. buttons.join.html_safe
  97. end
  98. end
  99. def social_meta_tags(post)
  100. return '' unless post
  101. (SocialSharing.open_graph_tags(post) + "\n" + SocialSharing.twitter_card_tags(post)).html_safe
  102. end
  103. end
  104. # Include helper
  105. if defined?(ApplicationController)
  106. ApplicationController.helper(SocialSharingHelper)
  107. end
  108. module SocialSharingHelper
  109. include ActionView::Helpers::UrlHelper
  110. include ActionView::Helpers::TagHelper
  111. include ActionView::Context
  112. def social_share_buttons(post, options = {})
  113. return '' unless post
  114. platforms = options[:platforms] || [:facebook, :twitter, :linkedin, :email]
  115. url = blog_post_url(post.slug) rescue "#"
  116. title = post.title
  117. buttons_html = platforms.map do |platform|
  118. share_button_for(platform, url, title)
  119. end.join
  120. content_tag :div, buttons_html.html_safe, class: 'flex items-center space-x-2'
  121. end
  122. def social_meta_tags(post)
  123. SocialSharing.open_graph_tags(post) + SocialSharing.twitter_card_tags(post)
  124. end
  125. private
  126. def share_button_for(platform, url, title)
  127. case platform
  128. when :facebook
  129. share_url = "https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(url)}"
  130. icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'
  131. bg_class = 'bg-blue-600 hover:bg-blue-700'
  132. when :twitter
  133. share_url = "https://twitter.com/intent/tweet?url=#{CGI.escape(url)}&text=#{CGI.escape(title)}"
  134. icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'
  135. bg_class = 'bg-sky-500 hover:bg-sky-600'
  136. when :linkedin
  137. share_url = "https://www.linkedin.com/shareArticle?mini=true&url=#{CGI.escape(url)}&title=#{CGI.escape(title)}"
  138. icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
  139. bg_class = 'bg-blue-700 hover:bg-blue-800'
  140. when :email
  141. share_url = "mailto:?subject=#{CGI.escape(title)}&body=#{CGI.escape(url)}"
  142. icon_svg = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>'
  143. bg_class = 'bg-gray-600 hover:bg-gray-700'
  144. else
  145. return ''
  146. end
  147. link_to share_url,
  148. target: '_blank',
  149. rel: 'noopener noreferrer',
  150. class: "p-2 #{bg_class} text-white rounded-lg transition",
  151. title: "Share on #{platform.to_s.titleize}" do
  152. icon_svg.html_safe
  153. end
  154. end
  155. end
  156. SocialSharing.new

lib/plugins/spam_protection/spam_protection.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # Spam Protection Plugin
  2. # Protects against comment spam using various techniques
  3. class SpamProtection < Railspress::PluginBase
  4. plugin_name 'Spam Protection'
  5. plugin_version '1.0.0'
  6. plugin_description 'Advanced spam protection for comments'
  7. plugin_author 'RailsPress'
  8. # Common spam keywords
  9. SPAM_KEYWORDS = %w[viagra cialis casino poker gambling loan mortgage]
  10. # Suspicious patterns
  11. SUSPICIOUS_PATTERNS = [
  12. /\b\d{10,}\b/, # Long numbers (phone/credit card)
  13. /<a\s+href/i, # HTML links
  14. /http.*http/i, # Multiple URLs
  15. /\[url=/i # BBCode links
  16. ]
  17. def activate
  18. super
  19. register_hooks
  20. end
  21. private
  22. def register_hooks
  23. # Filter comments before creation
  24. add_filter('comment_before_save', :check_for_spam)
  25. # Action after comment is flagged
  26. add_action('comment_marked_spam', :log_spam_attempt)
  27. end
  28. # Check if comment is spam
  29. def check_for_spam(comment)
  30. return comment unless comment.new_record?
  31. spam_score = calculate_spam_score(comment)
  32. if spam_score >= get_setting('spam_threshold', 3)
  33. comment.status = :spam
  34. Railspress::PluginSystem.do_action('comment_marked_spam', comment)
  35. end
  36. comment
  37. end
  38. def calculate_spam_score(comment)
  39. score = 0
  40. content = comment.content.to_s.downcase
  41. # Check for spam keywords
  42. SPAM_KEYWORDS.each do |keyword|
  43. score += 1 if content.include?(keyword)
  44. end
  45. # Check for suspicious patterns
  46. SUSPICIOUS_PATTERNS.each do |pattern|
  47. score += 1 if content.match?(pattern)
  48. end
  49. # Check for excessive links
  50. link_count = content.scan(/https?:\/\//).count
  51. score += 2 if link_count > 3
  52. # Check for ALL CAPS
  53. if content.length > 20 && content.upcase == content
  54. score += 1
  55. end
  56. # Check for repeated characters
  57. score += 1 if content.match?(/(.)\1{5,}/)
  58. # Very short comments with links are suspicious
  59. if content.length < 20 && link_count > 0
  60. score += 2
  61. end
  62. score
  63. end
  64. def log_spam_attempt(comment)
  65. Rails.logger.warn "Spam detected: #{comment.author_email} - Score: #{calculate_spam_score(comment)}"
  66. end
  67. # Public method to check if comment is likely spam
  68. def self.is_spam?(comment)
  69. plugin = new
  70. plugin.calculate_spam_score(comment) >= plugin.get_setting('spam_threshold', 3)
  71. end
  72. end
  73. # Extend Comment model
  74. if defined?(Comment)
  75. Comment.class_eval do
  76. before_validation :apply_spam_protection, on: :create
  77. private
  78. def apply_spam_protection
  79. if Railspress::PluginSystem.plugin_loaded?('Spam Protection')
  80. filtered = Railspress::PluginSystem.apply_filters('comment_before_save', self)
  81. end
  82. end
  83. end
  84. end
  85. SpamProtection.new

lib/plugins/uploadcare/uploadcare.rb

0.0% lines covered

100.0% branches covered

244 relevant lines. 0 lines covered and 244 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Uploadcare < Railspress::PluginBase
  2. plugin_name 'Uploadcare'
  3. plugin_version '1.0.0'
  4. plugin_description 'Professional media management and CDN with Uploadcare'
  5. plugin_author 'RailsPress Team'
  6. # Define comprehensive settings schema
  7. settings_schema do
  8. section 'API Configuration', description: 'Your Uploadcare API credentials' do
  9. text 'public_key', 'Public Key',
  10. description: 'Your Uploadcare public key (starts with your project ID)',
  11. required: true,
  12. placeholder: 'demopublickey',
  13. pattern: /\A[a-zA-Z0-9]+\z/
  14. text 'secret_key', 'Secret Key',
  15. description: 'Your Uploadcare secret key (keep this private!)',
  16. required: false,
  17. placeholder: 'demoprivatekey'
  18. end
  19. section 'Upload Widget', description: 'Configure the Uploadcare upload widget' do
  20. checkbox 'enable_widget', 'Enable Upload Widget',
  21. description: 'Show Uploadcare widget in admin media section',
  22. default: true
  23. select 'widget_theme', 'Widget Theme',
  24. [
  25. ['Light', 'light'],
  26. ['Dark', 'dark'],
  27. ['Minimal', 'minimal']
  28. ],
  29. description: 'Visual theme for the upload widget',
  30. default: 'light'
  31. checkbox 'multiple_files', 'Multiple File Upload',
  32. description: 'Allow uploading multiple files at once',
  33. default: true
  34. number 'max_file_size', 'Max File Size (MB)',
  35. description: 'Maximum file size for uploads',
  36. default: 25,
  37. min: 1,
  38. max: 100
  39. end
  40. section 'File Sources', description: 'Choose where users can upload files from' do
  41. checkbox 'source_local', 'Local Files',
  42. description: 'Upload from computer',
  43. default: true
  44. checkbox 'source_url', 'From URL',
  45. description: 'Import from URL',
  46. default: true
  47. checkbox 'source_camera', 'Camera',
  48. description: 'Capture from camera/webcam',
  49. default: true
  50. checkbox 'source_dropbox', 'Dropbox',
  51. description: 'Import from Dropbox',
  52. default: false
  53. checkbox 'source_gdrive', 'Google Drive',
  54. description: 'Import from Google Drive',
  55. default: false
  56. checkbox 'source_instagram', 'Instagram',
  57. description: 'Import from Instagram',
  58. default: false
  59. checkbox 'source_facebook', 'Facebook',
  60. description: 'Import from Facebook',
  61. default: false
  62. end
  63. section 'Image Processing', description: 'Automatic image transformations' do
  64. checkbox 'auto_crop', 'Auto Crop',
  65. description: 'Automatically crop images to focus area',
  66. default: false
  67. checkbox 'auto_rotate', 'Auto Rotate',
  68. description: 'Automatically rotate images based on EXIF',
  69. default: true
  70. select 'image_quality', 'Image Quality',
  71. [
  72. ['Normal', 'normal'],
  73. ['Better', 'better'],
  74. ['Best', 'best'],
  75. ['Lighter', 'lighter']
  76. ],
  77. description: 'Balance between quality and file size',
  78. default: 'normal'
  79. checkbox 'progressive_jpeg', 'Progressive JPEG',
  80. description: 'Convert JPEGs to progressive format',
  81. default: true
  82. checkbox 'strip_metadata', 'Strip Metadata',
  83. description: 'Remove EXIF data for privacy/smaller files',
  84. default: false
  85. end
  86. section 'CDN & Performance', description: 'Content delivery optimization' do
  87. checkbox 'use_cdn', 'Enable CDN',
  88. description: 'Serve files through Uploadcare CDN',
  89. default: true
  90. checkbox 'lazy_loading', 'Lazy Loading',
  91. description: 'Load images only when visible',
  92. default: true
  93. checkbox 'responsive_images', 'Responsive Images',
  94. description: 'Generate multiple sizes for different screens',
  95. default: true
  96. text 'cdn_base', 'Custom CDN Domain',
  97. description: 'Custom CNAME for CDN (leave blank for default)',
  98. placeholder: 'cdn.example.com'
  99. end
  100. section 'Dashboard', description: 'Uploadcare dashboard integration' do
  101. checkbox 'show_dashboard', 'Show Dashboard',
  102. description: 'Embed Uploadcare dashboard in admin',
  103. default: true
  104. radio 'dashboard_view', 'Default View',
  105. [
  106. ['Files', 'files'],
  107. ['Gallery', 'gallery'],
  108. ['Analytics', 'analytics']
  109. ],
  110. description: 'Default view when opening dashboard',
  111. default: 'files'
  112. end
  113. section 'Advanced', description: 'Advanced configuration options' do
  114. number 'retry_count', 'Upload Retry Count',
  115. description: 'Number of retry attempts for failed uploads',
  116. default: 3,
  117. min: 0,
  118. max: 10
  119. checkbox 'store_files', 'Store Files Permanently',
  120. description: 'Store files instead of deleting after 24 hours',
  121. default: true
  122. checkbox 'secure_signature', 'Secure Upload Signature',
  123. description: 'Require signed uploads (more secure)',
  124. default: false
  125. code 'custom_css', 'Custom Widget CSS',
  126. description: 'Custom CSS for the upload widget',
  127. language: 'css',
  128. placeholder: '.uploadcare-widget { border-radius: 8px; }'
  129. end
  130. end
  131. def initialize
  132. super
  133. setup_uploadcare if enabled?
  134. end
  135. def activate
  136. super
  137. Rails.logger.info "Uploadcare plugin activated"
  138. validate_api_credentials
  139. end
  140. def enabled?
  141. get_setting('enable_widget', true) &&
  142. get_setting('public_key').present?
  143. end
  144. # Get widget configuration
  145. def widget_config
  146. sources = []
  147. sources << 'local' if get_setting('source_local', true)
  148. sources << 'url' if get_setting('source_url', true)
  149. sources << 'camera' if get_setting('source_camera', true)
  150. sources << 'dropbox' if get_setting('source_dropbox', false)
  151. sources << 'gdrive' if get_setting('source_gdrive', false)
  152. sources << 'instagram' if get_setting('source_instagram', false)
  153. sources << 'facebook' if get_setting('source_facebook', false)
  154. {
  155. publicKey: get_setting('public_key'),
  156. multiple: get_setting('multiple_files', true),
  157. imagesOnly: false,
  158. previewStep: true,
  159. imageShrink: get_setting('responsive_images', true) ? '1024x1024' : false,
  160. multipleMax: get_setting('multiple_files', true) ? 10 : 1,
  161. tabs: sources.join(' '),
  162. systemDialog: false,
  163. locale: 'en',
  164. theme: get_setting('widget_theme', 'light'),
  165. crop: get_setting('auto_crop', false) ? 'free' : false
  166. }
  167. end
  168. # Get CDN URL for file
  169. def cdn_url(uuid, transformations = {})
  170. base = get_setting('cdn_base').presence || 'https://ucarecdn.com'
  171. url = "#{base}/#{uuid}/"
  172. if transformations.any?
  173. operations = []
  174. operations << "quality/#{transformations[:quality]}" if transformations[:quality]
  175. operations << "resize/#{transformations[:width]}x#{transformations[:height]}" if transformations[:width]
  176. operations << "crop/#{transformations[:crop]}" if transformations[:crop]
  177. operations << 'progressive/yes' if get_setting('progressive_jpeg', true)
  178. operations << 'autorotate/yes' if get_setting('auto_rotate', true)
  179. url += "#{operations.join('/')}/" if operations.any?
  180. end
  181. url
  182. end
  183. # Dashboard URL
  184. def dashboard_url
  185. project_id = get_setting('public_key')&.split('_')&.first
  186. return nil unless project_id
  187. view = get_setting('dashboard_view', 'files')
  188. "https://uploadcare.com/dashboard/#{project_id}/#{view}/"
  189. end
  190. private
  191. def setup_uploadcare
  192. # Register filters to inject Uploadcare widget
  193. add_filter('admin_head', 10) do |content|
  194. content + uploadcare_widget_script
  195. end
  196. # Register action to process uploaded files
  197. add_action('media_uploaded', 20) do |media|
  198. process_uploadcare_file(media)
  199. end
  200. end
  201. def uploadcare_widget_script
  202. return '' unless enabled?
  203. config = widget_config.to_json
  204. <<~HTML
  205. <!-- Uploadcare Widget -->
  206. <script>
  207. UPLOADCARE_PUBLIC_KEY = '#{get_setting('public_key')}';
  208. UPLOADCARE_TABS = '#{widget_config[:tabs]}';
  209. UPLOADCARE_LOCALE = 'en';
  210. </script>
  211. <script src="https://ucarecdn.com/libs/widget/3.x/uploadcare.full.min.js"></script>
  212. <link rel="stylesheet" href="https://ucarecdn.com/libs/widget/3.x/uploadcare.min.css" />
  213. #{custom_widget_css}
  214. HTML
  215. end
  216. def custom_widget_css
  217. css = get_setting('custom_css')
  218. return '' if css.blank?
  219. <<~HTML
  220. <style>
  221. #{css}
  222. </style>
  223. HTML
  224. end
  225. def process_uploadcare_file(media)
  226. # Store file permanently if setting is enabled
  227. if get_setting('store_files', true)
  228. store_file(media.uploadcare_uuid)
  229. end
  230. # Apply transformations
  231. if media.image? && get_setting('responsive_images', true)
  232. generate_responsive_versions(media)
  233. end
  234. end
  235. def store_file(uuid)
  236. # Call Uploadcare API to store file permanently
  237. return unless get_setting('secret_key').present?
  238. Rails.logger.info "Storing Uploadcare file: #{uuid}"
  239. # TODO: Implement actual API call
  240. end
  241. def generate_responsive_versions(media)
  242. # Generate responsive image versions
  243. Rails.logger.info "Generating responsive versions for: #{media.id}"
  244. # Handled by Uploadcare CDN on-the-fly
  245. end
  246. def validate_api_credentials
  247. public_key = get_setting('public_key')
  248. if public_key.blank?
  249. Rails.logger.warn "Uploadcare: No public key configured"
  250. return false
  251. end
  252. # TODO: Test API connection
  253. Rails.logger.info "Uploadcare: API credentials configured"
  254. true
  255. end
  256. end
  257. # Auto-initialize if active
  258. if Plugin.exists?(name: 'Uploadcare', active: true)
  259. Uploadcare.new
  260. end

lib/railspress/ai_agent_integration/channels.rb

0.0% lines covered

100.0% branches covered

131 relevant lines. 0 lines covered and 131 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module AiAgentIntegration
  3. # AI Agent integration for channels and content optimization
  4. module Channels
  5. extend ActiveSupport::Concern
  6. # Generate channel-specific content using AI
  7. def self.generate_content_for_channel(content, channel_slug, ai_agent_name = nil)
  8. channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
  9. return content unless channel
  10. # Get AI agent for content generation
  11. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'content_optimizer')
  12. return content unless agent&.active?
  13. # Get channel-specific settings
  14. settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
  15. # Create channel-aware prompt
  16. prompt = build_channel_prompt(content, channel, settings, agent)
  17. # Generate optimized content
  18. begin
  19. response = agent.generate_response(prompt)
  20. response.present? ? response : content
  21. rescue => e
  22. Rails.logger.error "AI content generation failed: #{e.message}"
  23. content
  24. end
  25. end
  26. # Optimize content for multiple channels
  27. def self.optimize_content_for_all_channels(content, ai_agent_name = nil)
  28. optimized_content = {}
  29. Channel.active.each do |channel|
  30. optimized_content[channel.slug] = generate_content_for_channel(content, channel.slug, ai_agent_name)
  31. end
  32. optimized_content
  33. end
  34. # Generate channel-specific meta descriptions
  35. def self.generate_meta_description(content, channel_slug, ai_agent_name = nil)
  36. channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
  37. return nil unless channel
  38. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'seo_analyzer')
  39. return nil unless agent&.active?
  40. settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
  41. prompt = "Generate a compelling meta description for #{channel.name} channel (max 160 characters):\n\nContent: #{content}\n\nChannel settings: #{settings.to_json}\n\nMeta description:"
  42. begin
  43. agent.generate_response(prompt)
  44. rescue => e
  45. Rails.logger.error "AI meta description generation failed: #{e.message}"
  46. nil
  47. end
  48. end
  49. # Generate channel-specific titles
  50. def self.generate_title(content, channel_slug, ai_agent_name = nil)
  51. channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
  52. return nil unless channel
  53. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'post_writer')
  54. return nil unless agent&.active?
  55. settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
  56. prompt = "Generate an engaging title for #{channel.name} channel:\n\nContent: #{content}\n\nChannel settings: #{settings.to_json}\n\nTitle:"
  57. begin
  58. agent.generate_response(prompt)
  59. rescue => e
  60. Rails.logger.error "AI title generation failed: #{e.message}"
  61. nil
  62. end
  63. end
  64. # Analyze content performance across channels
  65. def self.analyze_channel_performance(resource_type, resource_id, ai_agent_name = nil)
  66. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'comments_analyzer')
  67. return {} unless agent&.active?
  68. analysis = {}
  69. Channel.active.each do |channel|
  70. overrides = Railspress::PluginApi::Channels.resource_overrides(resource_type, resource_id, channel.slug)
  71. settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
  72. prompt = "Analyze content performance for #{channel.name} channel:\n\nResource: #{resource_type} ##{resource_id}\nOverrides: #{overrides.count}\nSettings: #{settings.to_json}\n\nAnalysis:"
  73. begin
  74. analysis[channel.slug] = agent.generate_response(prompt)
  75. rescue => e
  76. Rails.logger.error "AI channel analysis failed for #{channel.slug}: #{e.message}"
  77. analysis[channel.slug] = "Analysis failed"
  78. end
  79. end
  80. analysis
  81. end
  82. # Generate channel-specific recommendations
  83. def self.generate_recommendations(resource_type, resource_id, ai_agent_name = nil)
  84. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'seo_analyzer')
  85. return {} unless agent&.active?
  86. recommendations = {}
  87. Channel.active.each do |channel|
  88. overrides = Railspress::PluginApi::Channels.resource_overrides(resource_type, resource_id, channel.slug)
  89. settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
  90. prompt = "Generate optimization recommendations for #{channel.name} channel:\n\nResource: #{resource_type} ##{resource_id}\nCurrent overrides: #{overrides.count}\nChannel settings: #{settings.to_json}\n\nRecommendations:"
  91. begin
  92. recommendations[channel.slug] = agent.generate_response(prompt)
  93. rescue => e
  94. Rails.logger.error "AI recommendations failed for #{channel.slug}: #{e.message}"
  95. recommendations[channel.slug] = "Recommendations unavailable"
  96. end
  97. end
  98. recommendations
  99. end
  100. # Auto-create channel overrides based on AI analysis
  101. def self.auto_create_overrides(resource_type, resource_id, ai_agent_name = nil)
  102. agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'content_optimizer')
  103. return [] unless agent&.active?
  104. created_overrides = []
  105. Channel.active.each do |channel|
  106. settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
  107. prompt = "Suggest channel-specific overrides for #{channel.name}:\n\nResource: #{resource_type} ##{resource_id}\nChannel settings: #{settings.to_json}\n\nSuggest specific overrides in JSON format: {\"path\": \"setting.key\", \"data\": \"value\", \"kind\": \"override\"}"
  108. begin
  109. response = agent.generate_response(prompt)
  110. overrides_data = JSON.parse(response) rescue []
  111. overrides_data.each do |override_data|
  112. override = Railspress::PluginApi::Channels.create_override(
  113. channel.slug,
  114. resource_type,
  115. resource_id,
  116. override_data['path'],
  117. override_data['data'],
  118. override_data['kind'] || 'override'
  119. )
  120. created_overrides << override if override
  121. end
  122. rescue => e
  123. Rails.logger.error "AI override creation failed for #{channel.slug}: #{e.message}"
  124. end
  125. end
  126. created_overrides
  127. end
  128. private
  129. def self.build_channel_prompt(content, channel, settings, agent)
  130. base_prompt = agent.prompt || "Optimize content for the specified channel."
  131. "#{base_prompt}\n\n" \
  132. "Channel: #{channel.name} (#{channel.slug})\n" \
  133. "Target Audience: #{channel.metadata['target_audience']}\n" \
  134. "Device Type: #{channel.metadata['device_type']}\n" \
  135. "Screen Resolution: #{channel.metadata['screen_resolution']}\n" \
  136. "Input Method: #{channel.metadata['input_method']}\n" \
  137. "Channel Settings: #{settings.to_json}\n\n" \
  138. "Original Content:\n#{content}\n\n" \
  139. "Optimized Content:"
  140. end
  141. end
  142. end
  143. end

lib/railspress/ai_agent_plugin_helper.rb

0.0% lines covered

100.0% branches covered

81 relevant lines. 0 lines covered and 81 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module AiAgentPluginHelper
  3. # Easy access to AI Agents from plugins
  4. # Create a new AI Agent for your plugin
  5. #
  6. # Example:
  7. # Railspress::AiAgentPluginHelper.create_agent(
  8. # name: 'My Plugin Agent',
  9. # agent_type: 'custom_analyzer',
  10. # prompt: 'You are a custom analyzer...',
  11. # provider_type: 'openai'
  12. # )
  13. def self.create_agent(name:, agent_type:, prompt:, provider_type: 'openai', **options)
  14. # Find or create provider
  15. provider = AiProvider.find_by(provider_type: provider_type, active: true)
  16. unless provider
  17. raise "No active AI provider found for type: #{provider_type}. Please configure one in Admin > AI Agents > Providers"
  18. end
  19. # Create agent
  20. AiAgent.create!(
  21. name: name,
  22. agent_type: agent_type,
  23. prompt: prompt,
  24. ai_provider: provider,
  25. content: options[:content],
  26. guidelines: options[:guidelines],
  27. rules: options[:rules],
  28. tasks: options[:tasks],
  29. master_prompt: options[:master_prompt],
  30. active: options.fetch(:active, true),
  31. position: options.fetch(:position, 0)
  32. )
  33. end
  34. # Execute an AI Agent by type
  35. #
  36. # Example:
  37. # result = Railspress::AiAgentPluginHelper.execute('content_summarizer', 'Text to summarize')
  38. def self.execute(agent_type, input)
  39. agent = AiAgent.active.find_by(agent_type: agent_type)
  40. unless agent
  41. raise "No active agent found for type: #{agent_type}"
  42. end
  43. agent.execute(input)
  44. end
  45. # Execute an AI Agent by name
  46. #
  47. # Example:
  48. # result = Railspress::AiAgentPluginHelper.execute_by_name('My Custom Agent', 'Input text')
  49. def self.execute_by_name(name, input)
  50. agent = AiAgent.active.find_by(name: name)
  51. unless agent
  52. raise "No active agent found with name: #{name}"
  53. end
  54. agent.execute(input)
  55. end
  56. # List all available AI Agents
  57. def self.available_agents
  58. AiAgent.active.ordered
  59. end
  60. # List all available providers
  61. def self.available_providers
  62. AiProvider.active.ordered
  63. end
  64. # Check if an agent type exists
  65. def self.agent_exists?(agent_type)
  66. AiAgent.active.exists?(agent_type: agent_type)
  67. end
  68. # Get agent by type
  69. def self.get_agent(agent_type)
  70. AiAgent.active.find_by(agent_type: agent_type)
  71. end
  72. # Update agent settings
  73. #
  74. # Example:
  75. # Railspress::AiAgentPluginHelper.update_agent('content_summarizer',
  76. # prompt: 'New prompt...')
  77. def self.update_agent(agent_type, **attributes)
  78. agent = AiAgent.find_by(agent_type: agent_type)
  79. unless agent
  80. raise "Agent not found: #{agent_type}"
  81. end
  82. agent.update!(attributes)
  83. agent
  84. end
  85. # Delete an agent
  86. def self.delete_agent(agent_type)
  87. agent = AiAgent.find_by(agent_type: agent_type)
  88. agent&.destroy
  89. end
  90. # Batch execute multiple agents
  91. #
  92. # Example:
  93. # results = Railspress::AiAgentPluginHelper.batch_execute([
  94. # { type: 'content_summarizer', input: 'Text 1' },
  95. # { type: 'seo_analyzer', input: 'Text 2' }
  96. # ])
  97. def self.batch_execute(agent_requests)
  98. agent_requests.map do |request|
  99. {
  100. agent_type: request[:type],
  101. result: execute(request[:type], request[:input]),
  102. status: 'success'
  103. }
  104. rescue => e
  105. {
  106. agent_type: request[:type],
  107. error: e.message,
  108. status: 'error'
  109. }
  110. end
  111. end
  112. # Register a custom agent type (add to AiAgent::AGENT_TYPES)
  113. def self.register_agent_type(type, description = nil)
  114. unless AiAgent::AGENT_TYPES.include?(type)
  115. AiAgent::AGENT_TYPES << type
  116. end
  117. end
  118. end
  119. end

lib/railspress/html_sanitizer.rb

0.0% lines covered

100.0% branches covered

162 relevant lines. 0 lines covered and 162 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require 'loofah'
  3. module Railspress
  4. class HtmlSanitizer
  5. # Allow safe HTML tags and attributes for content editors
  6. ALLOWED_TAGS = %w[
  7. p br strong em b i u s strike del ins mark small sub sup
  8. h1 h2 h3 h4 h5 h6
  9. ul ol li dl dt dd
  10. blockquote q code pre kbd samp var
  11. a img
  12. table thead tbody tfoot tr th td caption colgroup col
  13. div span section article aside header footer nav main
  14. figure figcaption
  15. hr
  16. abbr cite dfn time
  17. ].freeze
  18. ALLOWED_ATTRIBUTES = {
  19. 'a' => %w[href title rel target],
  20. 'img' => %w[src alt title width height],
  21. 'div' => %w[class id],
  22. 'span' => %w[class id],
  23. 'p' => %w[class id],
  24. 'h1' => %w[id],
  25. 'h2' => %w[id],
  26. 'h3' => %w[id],
  27. 'h4' => %w[id],
  28. 'h5' => %w[id],
  29. 'h6' => %w[id],
  30. 'section' => %w[class id],
  31. 'article' => %w[class id],
  32. 'table' => %w[class],
  33. 'tr' => %w[class],
  34. 'td' => %w[class colspan rowspan],
  35. 'th' => %w[class colspan rowspan],
  36. 'code' => %w[class],
  37. 'pre' => %w[class],
  38. 'blockquote' => %w[cite]
  39. }.freeze
  40. ALLOWED_PROTOCOLS = %w[http https mailto].freeze
  41. class << self
  42. # Sanitize HTML content for posts/pages
  43. def sanitize_content(html)
  44. return '' if html.blank?
  45. scrubber = ContentScrubber.new
  46. Loofah.fragment(html).scrub!(scrubber).to_s
  47. end
  48. # Sanitize HTML from GrapesJS (template editor)
  49. def sanitize_template(html)
  50. return '' if html.blank?
  51. scrubber = TemplateScrubber.new
  52. Loofah.fragment(html).scrub!(scrubber).to_s
  53. end
  54. # Strip all HTML tags, leaving only text
  55. def strip_tags(html)
  56. return '' if html.blank?
  57. Loofah.fragment(html).text(encode_special_chars: false)
  58. end
  59. # Sanitize for safe display in admin (allows more tags)
  60. def sanitize_admin(html)
  61. return '' if html.blank?
  62. scrubber = AdminScrubber.new
  63. Loofah.fragment(html).scrub!(scrubber).to_s
  64. end
  65. # Check if HTML contains any disallowed content
  66. def contains_unsafe_content?(html)
  67. return false if html.blank?
  68. # Check for script tags
  69. return true if html.match?(/<script[\s>]/i)
  70. # Check for event handlers
  71. return true if html.match?(/on\w+\s*=/i)
  72. # Check for javascript: protocol
  73. return true if html.match?(/javascript:/i)
  74. # Check for data: protocol (except images)
  75. return true if html.match?(/data:(?!image)/i)
  76. false
  77. end
  78. end
  79. # Scrubber for content (posts/pages)
  80. class ContentScrubber < Loofah::Scrubber
  81. def initialize
  82. @direction = :top_down
  83. end
  84. def scrub(node)
  85. return CONTINUE if node.text?
  86. # Remove comments
  87. return STOP if node.comment?
  88. # Check if tag is allowed
  89. unless ALLOWED_TAGS.include?(node.name)
  90. node.before(node.children)
  91. return STOP
  92. end
  93. # Remove dangerous attributes
  94. node.attributes.each do |name, _attr|
  95. # Skip if attribute is allowed for this tag
  96. next if ALLOWED_ATTRIBUTES[node.name]&.include?(name)
  97. # Remove attribute
  98. node.remove_attribute(name)
  99. end
  100. # Sanitize href/src attributes
  101. sanitize_url_attributes(node)
  102. CONTINUE
  103. end
  104. private
  105. def sanitize_url_attributes(node)
  106. %w[href src].each do |attr|
  107. next unless node[attr]
  108. url = node[attr].strip
  109. # Remove javascript: and data: protocols
  110. if url.match?(/^(javascript|data):/i)
  111. node.remove_attribute(attr)
  112. next
  113. end
  114. # Ensure protocol is allowed
  115. if url.match?(/^(\w+):/) && !ALLOWED_PROTOCOLS.any? { |p| url.start_with?("#{p}:") }
  116. node.remove_attribute(attr)
  117. end
  118. end
  119. end
  120. end
  121. # Scrubber for templates (GrapesJS)
  122. class TemplateScrubber < ContentScrubber
  123. # Additional allowed tags for templates
  124. TEMPLATE_TAGS = (ALLOWED_TAGS + %w[
  125. style
  126. ]).freeze
  127. # Additional allowed attributes for templates
  128. TEMPLATE_ATTRIBUTES = ALLOWED_ATTRIBUTES.merge(
  129. 'style' => %w[type],
  130. 'div' => %w[class id data-gjs-type],
  131. 'section' => %w[class id data-gjs-type]
  132. ).freeze
  133. def scrub(node)
  134. return CONTINUE if node.text?
  135. return STOP if node.comment?
  136. # Allow more tags for templates
  137. unless TEMPLATE_TAGS.include?(node.name)
  138. node.before(node.children)
  139. return STOP
  140. end
  141. # Remove dangerous attributes but keep template-specific ones
  142. node.attributes.each do |name, _attr|
  143. next if TEMPLATE_ATTRIBUTES[node.name]&.include?(name)
  144. node.remove_attribute(name)
  145. end
  146. # For style tags, sanitize content
  147. if node.name == 'style'
  148. sanitize_css(node)
  149. end
  150. sanitize_url_attributes(node)
  151. CONTINUE
  152. end
  153. private
  154. def sanitize_css(node)
  155. # Remove any @import or expression() from CSS
  156. css = node.content
  157. css.gsub!(/@import/i, '')
  158. css.gsub!(/expression\s*\(/i, '')
  159. css.gsub!(/javascript:/i, '')
  160. node.content = css
  161. end
  162. end
  163. # Scrubber for admin interface (most permissive)
  164. class AdminScrubber < TemplateScrubber
  165. # Admin can see more but still no scripts
  166. ADMIN_TAGS = (TEMPLATE_TAGS + %w[
  167. video audio source track
  168. iframe embed object
  169. canvas svg
  170. details summary
  171. ]).freeze
  172. def scrub(node)
  173. return CONTINUE if node.text?
  174. return STOP if node.comment?
  175. # Block script tags always
  176. if node.name == 'script'
  177. return STOP
  178. end
  179. # Allow admin tags
  180. unless ADMIN_TAGS.include?(node.name)
  181. node.before(node.children)
  182. return STOP
  183. end
  184. # Remove event handler attributes
  185. node.attributes.each do |name, _attr|
  186. if name.match?(/^on/i)
  187. node.remove_attribute(name)
  188. end
  189. end
  190. CONTINUE
  191. end
  192. end
  193. end
  194. end

lib/railspress/liquid/consent_tags.rb

33.03% lines covered

0.0% branches covered

218 relevant lines. 72 lines covered and 146 lines missed.
82 total branches, 0 branches covered and 82 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Railspress
  3. 1 module Liquid
  4. 1 module ConsentTags
  5. # Register consent-related Liquid tags
  6. 1 def self.register_tags
  7. 2 ::Liquid::Template.register_tag('consent_banner', ConsentBannerTag)
  8. 2 ::Liquid::Template.register_tag('consent_css', ConsentCssTag)
  9. 2 ::Liquid::Template.register_tag('consent_pixel', ConsentPixelTag)
  10. 2 ::Liquid::Template.register_tag('consent_script', ConsentScriptTag)
  11. 2 ::Liquid::Template.register_tag('consent_status', ConsentStatusTag)
  12. 2 ::Liquid::Template.register_tag('consent_management_link', ConsentManagementLinkTag)
  13. 2 ::Liquid::Template.register_tag('consent_assets', ConsentAssetsTag)
  14. 2 ::Liquid::Template.register_tag('consent_config', ConsentConfigTag)
  15. 2 ::Liquid::Template.register_tag('consent_analytics', ConsentAnalyticsTag)
  16. 2 ::Liquid::Template.register_tag('consent_compliance', ConsentComplianceTag)
  17. end
  18. end
  19. # Render consent banner
  20. 1 class ConsentBannerTag < ::Liquid::Tag
  21. 1 def initialize(tag_name, markup, options)
  22. super
  23. @markup = markup.strip
  24. end
  25. 1 def render(context)
  26. # Get consent configuration
  27. consent_config = ConsentConfiguration.active.first
  28. else: 0 then: 0 return '' unless consent_config
  29. # Get user's region and consent data
  30. region = get_user_region(context)
  31. user_consent = get_user_consent_data(context)
  32. # Generate banner HTML
  33. consent_config.generate_banner_html(region, user_consent)
  34. end
  35. 1 private
  36. 1 def get_user_region(context)
  37. # Try to get region from context or request
  38. context['user_region'] ||
  39. then: 0 else: 0 context['request']&.remote_ip ||
  40. 'unknown'
  41. end
  42. 1 def get_user_consent_data(context)
  43. # Get user consent data from context
  44. user = context['user']
  45. then: 0 else: 0 else: 0 then: 0 return [] unless user&.respond_to?(:user_consents)
  46. user.user_consents.map do |consent|
  47. {
  48. consent_type: consent.consent_type,
  49. granted: consent.granted?
  50. }
  51. end
  52. end
  53. end
  54. # Render consent CSS
  55. 1 class ConsentCssTag < ::Liquid::Tag
  56. 1 def render(context)
  57. consent_config = ConsentConfiguration.active.first
  58. else: 0 then: 0 return '' unless consent_config
  59. consent_config.generate_banner_css
  60. end
  61. end
  62. # Render consent-aware pixel
  63. 1 class ConsentPixelTag < ::Liquid::Tag
  64. 1 def initialize(tag_name, markup, options)
  65. super
  66. @markup = markup.strip
  67. end
  68. 1 def render(context)
  69. # Parse pixel ID from markup
  70. pixel_id = @markup.split.first
  71. else: 0 then: 0 return '' unless pixel_id
  72. # Find pixel
  73. pixel = Pixel.find_by(id: pixel_id)
  74. then: 0 else: 0 else: 0 then: 0 return '' unless pixel&.active?
  75. # Get consent configuration
  76. consent_config = ConsentConfiguration.active.first
  77. else: 0 then: 0 return pixel.render_code unless consent_config
  78. # Check if pixel requires consent
  79. required_consent = consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
  80. if required_consent.any?
  81. then: 0 # Pixel requires consent - wrap in consent-aware code
  82. consent_categories = required_consent.join(',')
  83. <<~HTML
  84. <div data-pixel-type="#{pixel.pixel_type}" data-consent-categories="#{consent_categories}" class="consent-pixel" style="display: none;">
  85. #{pixel.render_code}
  86. </div>
  87. HTML
  88. else
  89. else: 0 # Pixel doesn't require consent - render normally
  90. pixel.render_code
  91. end
  92. end
  93. end
  94. # Render consent script
  95. 1 class ConsentScriptTag < ::Liquid::Tag
  96. 1 def initialize(tag_name, markup, options)
  97. super
  98. @markup = markup.strip
  99. end
  100. 1 def render(context)
  101. script_type = @markup.split.first || 'init'
  102. case script_type
  103. when: 0 when 'init'
  104. render_init_script(context)
  105. when: 0 when 'config'
  106. render_config_script(context)
  107. when: 0 when 'pixel'
  108. render_pixel_script(context)
  109. when: 0 when 'analytics'
  110. render_analytics_script(context)
  111. else: 0 else
  112. ''
  113. end
  114. end
  115. 1 private
  116. 1 def render_init_script(context)
  117. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  118. config_json = get_consent_config_json(context)
  119. <<~HTML
  120. <script>
  121. document.addEventListener('DOMContentLoaded', function() {
  122. // Initialize consent manager
  123. if (typeof ConsentManager !== 'undefined') {
  124. window.consentManager = new ConsentManager({
  125. config: #{config_json},
  126. debug: #{Rails.env.development?}
  127. });
  128. }
  129. });
  130. </script>
  131. HTML
  132. end
  133. 1 def render_config_script(context)
  134. consent_config = ConsentConfiguration.active.first
  135. else: 0 then: 0 return '' unless consent_config
  136. config_json = get_consent_config_json(context)
  137. <<~HTML
  138. <script>
  139. window.consentConfig = #{config_json};
  140. </script>
  141. HTML
  142. end
  143. 1 def render_pixel_script(context)
  144. <<~HTML
  145. <script>
  146. // Consent-aware pixel loading
  147. document.addEventListener('DOMContentLoaded', function() {
  148. const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
  149. consentPixels.forEach(function(pixel) {
  150. const pixelType = pixel.dataset.pixelType;
  151. const requiredCategories = pixel.dataset.consentCategories.split(',');
  152. // Check if user has required consent
  153. let hasConsent = true;
  154. if (window.userConsentData) {
  155. hasConsent = requiredCategories.every(function(category) {
  156. return window.userConsentData[category] && window.userConsentData[category].granted;
  157. });
  158. }
  159. if (hasConsent) {
  160. pixel.style.display = '';
  161. pixel.classList.remove('consent-disabled');
  162. } else {
  163. pixel.style.display = 'none';
  164. pixel.classList.add('consent-disabled');
  165. }
  166. });
  167. });
  168. </script>
  169. HTML
  170. end
  171. 1 def render_analytics_script(context)
  172. <<~HTML
  173. <script>
  174. // Consent-aware analytics
  175. document.addEventListener('DOMContentLoaded', function() {
  176. // Track consent events
  177. if (window.consentManager) {
  178. window.consentManager.on('consent_granted', function(data) {
  179. // Track consent granted event
  180. if (typeof gtag !== 'undefined') {
  181. gtag('event', 'consent_granted', {
  182. 'event_category': 'consent',
  183. 'event_label': data.category
  184. });
  185. }
  186. });
  187. window.consentManager.on('consent_withdrawn', function(data) {
  188. // Track consent withdrawn event
  189. if (typeof gtag !== 'undefined') {
  190. gtag('event', 'consent_withdrawn', {
  191. 'event_category': 'consent',
  192. 'event_label': data.category
  193. });
  194. }
  195. });
  196. }
  197. });
  198. </script>
  199. HTML
  200. end
  201. 1 def get_consent_config_json(context)
  202. consent_config = ConsentConfiguration.active.first
  203. else: 0 then: 0 return '{}' unless consent_config
  204. {
  205. consent_categories: consent_config.consent_categories_with_defaults,
  206. banner_settings: consent_config.banner_settings_with_defaults,
  207. geolocation_settings: consent_config.geolocation_settings_with_defaults,
  208. pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
  209. version: consent_config.version || '1.0'
  210. }.to_json
  211. end
  212. end
  213. # Render consent status
  214. 1 class ConsentStatusTag < ::Liquid::Tag
  215. 1 def initialize(tag_name, markup, options)
  216. super
  217. @markup = markup.strip
  218. end
  219. 1 def render(context)
  220. category = @markup.split.first
  221. else: 0 then: 0 return '' unless category
  222. user = context['user']
  223. then: 0 else: 0 else: 0 then: 0 return '' unless user&.respond_to?(:user_consents)
  224. consent = user.user_consents.find_by(consent_type: category)
  225. else: 0 then: 0 return '' unless consent
  226. then: 0 else: 0 status_class = consent.granted? ? 'consent-granted' : 'consent-withdrawn'
  227. then: 0 else: 0 status_text = consent.granted? ? 'Granted' : 'Withdrawn'
  228. "<span class=\"consent-status #{status_class}\">#{status_text}</span>"
  229. end
  230. end
  231. # Render consent management link
  232. 1 class ConsentManagementLinkTag < ::Liquid::Tag
  233. 1 def initialize(tag_name, markup, options)
  234. super
  235. @markup = markup.strip
  236. end
  237. 1 def render(context)
  238. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  239. then: 0 else: 0 text = @markup.present? ? @markup : 'Manage Cookie Preferences'
  240. "<a href=\"#\" class=\"consent-management-link\" onclick=\"ConsentManager.showPreferencesModal(); return false;\">#{text}</a>"
  241. end
  242. end
  243. # Render all consent assets
  244. 1 class ConsentAssetsTag < ::Liquid::Tag
  245. 1 def initialize(tag_name, markup, options)
  246. super
  247. @markup = markup.strip
  248. end
  249. 1 def render(context)
  250. else: 0 then: 0 return '' unless ConsentConfiguration.active.exists?
  251. consent_config = ConsentConfiguration.active.first
  252. # Get user's region and consent data
  253. region = get_user_region(context)
  254. user_consent = get_user_consent_data(context)
  255. # Generate all assets
  256. css = consent_config.generate_banner_css
  257. html = consent_config.generate_banner_html(region, user_consent)
  258. script = generate_consent_script(context)
  259. <<~HTML
  260. <style>
  261. #{css}
  262. </style>
  263. #{html}
  264. #{script}
  265. HTML
  266. end
  267. 1 private
  268. 1 def get_user_region(context)
  269. context['user_region'] ||
  270. then: 0 else: 0 context['request']&.remote_ip ||
  271. 'unknown'
  272. end
  273. 1 def get_user_consent_data(context)
  274. user = context['user']
  275. then: 0 else: 0 else: 0 then: 0 return [] unless user&.respond_to?(:user_consents)
  276. user.user_consents.map do |consent|
  277. {
  278. consent_type: consent.consent_type,
  279. granted: consent.granted?
  280. }
  281. end
  282. end
  283. 1 def generate_consent_script(context)
  284. config_json = get_consent_config_json(context)
  285. <<~HTML
  286. <script>
  287. document.addEventListener('DOMContentLoaded', function() {
  288. // Initialize consent manager
  289. if (typeof ConsentManager !== 'undefined') {
  290. window.consentManager = new ConsentManager({
  291. config: #{config_json},
  292. debug: #{Rails.env.development?}
  293. });
  294. }
  295. // Handle consent-aware pixels
  296. const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
  297. consentPixels.forEach(function(pixel) {
  298. const pixelType = pixel.dataset.pixelType;
  299. const requiredCategories = pixel.dataset.consentCategories.split(',');
  300. let hasConsent = true;
  301. if (window.userConsentData) {
  302. hasConsent = requiredCategories.every(function(category) {
  303. return window.userConsentData[category] && window.userConsentData[category].granted;
  304. });
  305. }
  306. if (hasConsent) {
  307. pixel.style.display = '';
  308. pixel.classList.remove('consent-disabled');
  309. } else {
  310. pixel.style.display = 'none';
  311. pixel.classList.add('consent-disabled');
  312. }
  313. });
  314. });
  315. </script>
  316. HTML
  317. end
  318. 1 def get_consent_config_json(context)
  319. consent_config = ConsentConfiguration.active.first
  320. else: 0 then: 0 return '{}' unless consent_config
  321. {
  322. consent_categories: consent_config.consent_categories_with_defaults,
  323. banner_settings: consent_config.banner_settings_with_defaults,
  324. geolocation_settings: consent_config.geolocation_settings_with_defaults,
  325. pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
  326. version: consent_config.version || '1.0'
  327. }.to_json
  328. end
  329. end
  330. # Render consent configuration
  331. 1 class ConsentConfigTag < ::Liquid::Tag
  332. 1 def initialize(tag_name, markup, options)
  333. super
  334. @markup = markup.strip
  335. end
  336. 1 def render(context)
  337. consent_config = ConsentConfiguration.active.first
  338. else: 0 then: 0 return '{}' unless consent_config
  339. config_type = @markup.split.first || 'all'
  340. case config_type
  341. when: 0 when 'categories'
  342. consent_config.consent_categories_with_defaults.to_json
  343. when: 0 when 'banner'
  344. consent_config.banner_settings_with_defaults.to_json
  345. when: 0 when 'geolocation'
  346. consent_config.geolocation_settings_with_defaults.to_json
  347. when: 0 when 'pixels'
  348. consent_config.pixel_consent_mapping_with_defaults.to_json
  349. else: 0 else
  350. {
  351. consent_categories: consent_config.consent_categories_with_defaults,
  352. banner_settings: consent_config.banner_settings_with_defaults,
  353. geolocation_settings: consent_config.geolocation_settings_with_defaults,
  354. pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
  355. version: consent_config.version || '1.0'
  356. }.to_json
  357. end
  358. end
  359. end
  360. # Render consent analytics
  361. 1 class ConsentAnalyticsTag < ::Liquid::Tag
  362. 1 def initialize(tag_name, markup, options)
  363. super
  364. @markup = markup.strip
  365. end
  366. 1 def render(context)
  367. analytics_type = @markup.split.first || 'events'
  368. case analytics_type
  369. when: 0 when 'events'
  370. render_consent_events(context)
  371. when: 0 when 'stats'
  372. render_consent_stats(context)
  373. when: 0 when 'compliance'
  374. render_compliance_stats(context)
  375. else: 0 else
  376. ''
  377. end
  378. end
  379. 1 private
  380. 1 def render_consent_events(context)
  381. <<~HTML
  382. <script>
  383. // Consent analytics events
  384. document.addEventListener('DOMContentLoaded', function() {
  385. // Track consent banner interactions
  386. document.addEventListener('click', function(e) {
  387. if (e.target.matches('.consent-btn')) {
  388. const action = e.target.textContent.toLowerCase().replace(/\s+/g, '_');
  389. if (typeof gtag !== 'undefined') {
  390. gtag('event', 'consent_banner_' + action, {
  391. 'event_category': 'consent',
  392. 'event_label': 'banner_interaction'
  393. });
  394. }
  395. }
  396. });
  397. // Track consent preference changes
  398. document.addEventListener('change', function(e) {
  399. if (e.target.matches('.consent-toggle input[type="checkbox"]')) {
  400. const category = e.target.dataset.category;
  401. const action = e.target.checked ? 'enabled' : 'disabled';
  402. if (typeof gtag !== 'undefined') {
  403. gtag('event', 'consent_preference_' + action, {
  404. 'event_category': 'consent',
  405. 'event_label': category
  406. });
  407. }
  408. }
  409. });
  410. });
  411. </script>
  412. HTML
  413. end
  414. 1 def render_consent_stats(context)
  415. # Get consent statistics
  416. stats = {
  417. total_consents: UserConsent.count,
  418. granted_consents: UserConsent.granted.count,
  419. withdrawn_consents: UserConsent.withdrawn.count,
  420. consent_rate: calculate_consent_rate
  421. }
  422. <<~HTML
  423. <script>
  424. window.consentStats = #{stats.to_json};
  425. </script>
  426. HTML
  427. end
  428. 1 def render_compliance_stats(context)
  429. # Get compliance statistics
  430. compliance = {
  431. gdpr_compliant: check_gdpr_compliance,
  432. ccpa_compliant: check_ccpa_compliance,
  433. overall_score: calculate_overall_compliance_score
  434. }
  435. <<~HTML
  436. <script>
  437. window.complianceStats = #{compliance.to_json};
  438. </script>
  439. HTML
  440. end
  441. 1 def calculate_consent_rate
  442. total_users = User.count
  443. users_with_consent = User.joins(:user_consents).distinct.count
  444. then: 0 else: 0 return 0 if total_users == 0
  445. (users_with_consent.to_f / total_users * 100).round(2)
  446. end
  447. 1 def check_gdpr_compliance
  448. # Simplified GDPR compliance check
  449. {
  450. data_subject_rights: UserConsent.exists?,
  451. consent_management: ConsentConfiguration.active.exists?,
  452. data_processing_records: true,
  453. privacy_by_design: true,
  454. score: 85
  455. }
  456. end
  457. 1 def check_ccpa_compliance
  458. # Simplified CCPA compliance check
  459. {
  460. consumer_rights: PersonalDataExportRequest.exists?,
  461. opt_out_mechanism: UserConsent.withdrawn.exists?,
  462. data_disclosure: true,
  463. score: 80
  464. }
  465. end
  466. 1 def calculate_overall_compliance_score
  467. gdpr_score = check_gdpr_compliance[:score]
  468. ccpa_score = check_ccpa_compliance[:score]
  469. ((gdpr_score + ccpa_score) / 2.0).round(2)
  470. end
  471. end
  472. # Render compliance information
  473. 1 class ConsentComplianceTag < ::Liquid::Tag
  474. 1 def initialize(tag_name, markup, options)
  475. super
  476. @markup = markup.strip
  477. end
  478. 1 def render(context)
  479. compliance_type = @markup.split.first || 'status'
  480. case compliance_type
  481. when: 0 when 'status'
  482. render_compliance_status(context)
  483. when: 0 when 'score'
  484. render_compliance_score(context)
  485. when: 0 when 'report'
  486. render_compliance_report(context)
  487. else: 0 else
  488. ''
  489. end
  490. end
  491. 1 private
  492. 1 def render_compliance_status(context)
  493. score = calculate_overall_compliance_score
  494. when: 0 status_class = case score
  495. when: 0 when 90..100 then 'excellent'
  496. when: 0 when 80..89 then 'good'
  497. else: 0 when 70..79 then 'fair'
  498. else 'needs-improvement'
  499. end
  500. when: 0 status_text = case score
  501. when: 0 when 90..100 then 'Excellent'
  502. when: 0 when 80..89 then 'Good'
  503. else: 0 when 70..79 then 'Fair'
  504. else 'Needs Improvement'
  505. end
  506. "<span class=\"compliance-status #{status_class}\">#{status_text} (#{score}%)</span>"
  507. end
  508. 1 def render_compliance_score(context)
  509. score = calculate_overall_compliance_score
  510. "<span class=\"compliance-score\">#{score}%</span>"
  511. end
  512. 1 def render_compliance_report(context)
  513. report = generate_compliance_report
  514. <<~HTML
  515. <div class="compliance-report">
  516. <h3>Privacy Compliance Report</h3>
  517. <div class="compliance-section">
  518. <h4>GDPR Compliance</h4>
  519. <p>Score: #{report[:gdpr_compliance][:score]}%</p>
  520. </div>
  521. <div class="compliance-section">
  522. <h4>CCPA Compliance</h4>
  523. <p>Score: #{report[:ccpa_compliance][:score]}%</p>
  524. </div>
  525. <div class="compliance-section">
  526. <h4>Overall Score</h4>
  527. <p>Score: #{report[:overall_score]}%</p>
  528. </div>
  529. </div>
  530. HTML
  531. end
  532. 1 def calculate_overall_compliance_score
  533. gdpr_score = 85 # Simplified
  534. ccpa_score = 80 # Simplified
  535. ((gdpr_score + ccpa_score) / 2.0).round(2)
  536. end
  537. 1 def generate_compliance_report
  538. {
  539. gdpr_compliance: {
  540. score: 85,
  541. data_subject_rights: UserConsent.exists?,
  542. consent_management: ConsentConfiguration.active.exists?,
  543. data_processing_records: true,
  544. privacy_by_design: true
  545. },
  546. ccpa_compliance: {
  547. score: 80,
  548. consumer_rights: PersonalDataExportRequest.exists?,
  549. opt_out_mechanism: UserConsent.withdrawn.exists?,
  550. data_disclosure: true
  551. },
  552. overall_score: calculate_overall_compliance_score
  553. }
  554. end
  555. end
  556. end
  557. end
  558. # Register the consent tags
  559. 1 Railspress::Liquid::ConsentTags.register_tags

lib/railspress/liquid/image_optimization_tags.rb

33.33% lines covered

0.0% branches covered

99 relevant lines. 33 lines covered and 66 lines missed.
26 total branches, 0 branches covered and 26 branches missed.
    
  1. # frozen_string_literal: true
  2. # Image optimization tag for responsive images with WebP/AVIF support
  3. 1 class ImageOptimizedTag < Liquid::Tag
  4. 1 def initialize(tag_name, markup, options)
  5. super
  6. @markup = markup.strip
  7. end
  8. 1 def render(context)
  9. parsed = parse_markup
  10. else: 0 then: 0 return '' unless parsed[:src]
  11. upload = get_upload(context)
  12. then: 0 else: 0 then: 0 else: 0 else: 0 then: 0 return '' unless upload&.file_attachment&.attached?
  13. generate_responsive_image(upload, context)
  14. end
  15. 1 private
  16. 1 def parse_markup
  17. attributes = {}
  18. @markup.scan(/(\w+)=["']([^"']*)["']/) do |key, value|
  19. attributes[key.to_sym] = value
  20. end
  21. attributes
  22. rescue
  23. {}
  24. end
  25. 1 def get_upload(context)
  26. else: 0 then: 0 return nil unless @markup.include?('upload=')
  27. upload_id = @markup.match(/upload=["'](\d+)["']/)[1]
  28. Upload.find_by(id: upload_id)
  29. rescue
  30. nil
  31. end
  32. 1 def generate_responsive_image(upload, context)
  33. alt_text = @markup.match(/alt=["']([^"']*)["']/)[1] rescue 'Image'
  34. css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue ''
  35. # Generate source sets for different formats
  36. webp_srcset = generate_source_set(upload, 'webp', 'image/webp')
  37. avif_srcset = generate_source_set(upload, 'avif', 'image/avif')
  38. # Fallback srcset for original format
  39. original_srcset = generate_srcset(upload, upload.file_type)
  40. # Generate the picture element
  41. <<~HTML
  42. <picture>
  43. <source srcset="#{avif_srcset}" type="image/avif">
  44. <source srcset="#{webp_srcset}" type="image/webp">
  45. <img src="#{upload.file_url}"
  46. srcset="#{original_srcset}"
  47. alt="#{alt_text}"
  48. class="#{css_class}"
  49. loading="lazy">
  50. </picture>
  51. #{generate_lazy_loading_script}
  52. HTML
  53. end
  54. 1 def generate_source_set(upload, format, mime_type)
  55. # Generate srcset for optimized format
  56. generate_srcset(upload, format)
  57. end
  58. 1 def generate_srcset(upload, format)
  59. then: 0 else: 0 else: 0 then: 0 return upload.file_url unless upload.variants&.dig(format)
  60. variants = upload.variants[format]
  61. srcset_parts = []
  62. variants.each do |size, url|
  63. srcset_parts << "#{url} #{size}w"
  64. end
  65. srcset_parts.join(', ')
  66. rescue
  67. upload.file_url
  68. end
  69. 1 def generate_img_tag(upload, context)
  70. alt_text = @markup.match(/alt=["']([^"']*)["']/)[1] rescue 'Image'
  71. css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue ''
  72. "<img src=\"#{upload.file_url}\" alt=\"#{alt_text}\" class=\"#{css_class}\" loading=\"lazy\">"
  73. end
  74. 1 def generate_lazy_loading_script
  75. <<~HTML
  76. <script>
  77. if ('IntersectionObserver' in window) {
  78. const images = document.querySelectorAll('img[loading="lazy"]');
  79. const imageObserver = new IntersectionObserver((entries, observer) => {
  80. entries.forEach(entry => {
  81. if (entry.isIntersecting) {
  82. const img = entry.target;
  83. img.src = img.dataset.src || img.src;
  84. img.classList.remove('lazy');
  85. imageObserver.unobserve(img);
  86. }
  87. });
  88. });
  89. images.forEach(img => imageObserver.observe(img));
  90. }
  91. </script>
  92. HTML
  93. end
  94. end
  95. # Background image optimization tag
  96. 1 class BackgroundImageOptimizedTag < Liquid::Tag
  97. 1 def initialize(tag_name, markup, options)
  98. super
  99. @markup = markup.strip
  100. end
  101. 1 def render(context)
  102. parsed = parse_markup
  103. else: 0 then: 0 return '' unless parsed[:upload]
  104. upload = get_upload(context)
  105. then: 0 else: 0 then: 0 else: 0 else: 0 then: 0 return '' unless upload&.file_attachment&.attached?
  106. generate_background_image_css(upload, context)
  107. end
  108. 1 private
  109. 1 def parse_markup
  110. attributes = {}
  111. @markup.scan(/(\w+)=["']([^"']*)["']/) do |key, value|
  112. attributes[key.to_sym] = value
  113. end
  114. attributes
  115. rescue
  116. {}
  117. end
  118. 1 def get_upload(context)
  119. else: 0 then: 0 return nil unless @markup.include?('upload=')
  120. upload_id = @markup.match(/upload=["'](\d+)["']/)[1]
  121. Upload.find_by(id: upload_id)
  122. rescue
  123. nil
  124. end
  125. 1 def generate_background_image_css(upload, context)
  126. css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue 'bg-image'
  127. # Generate CSS with fallbacks
  128. <<~CSS
  129. <style>
  130. .#{css_class} {
  131. background-image: url('#{upload.file_url}');
  132. background-size: cover;
  133. background-position: center;
  134. background-repeat: no-repeat;
  135. }
  136. @supports (background-image: url('#{upload.file_url}')) {
  137. .#{css_class} {
  138. background-image: url('#{upload.file_url}');
  139. }
  140. }
  141. </style>
  142. CSS
  143. end
  144. end
  145. # Bulk optimization tag for admin use
  146. 1 class BulkOptimizeTag < Liquid::Tag
  147. 1 def initialize(tag_name, markup, options)
  148. super
  149. end
  150. 1 def render(context)
  151. generate_bulk_optimization_interface
  152. end
  153. 1 private
  154. 1 def generate_bulk_optimization_interface
  155. <<~HTML
  156. <div class="bulk-optimization-interface">
  157. <h3>Bulk Image Optimization</h3>
  158. <div class="optimization-controls">
  159. <button id="start-optimization" class="btn btn-primary">
  160. Start Optimization
  161. </button>
  162. <button id="stop-optimization" class="btn btn-secondary" disabled>
  163. Stop Optimization
  164. </button>
  165. </div>
  166. <div class="optimization-progress" style="display: none;">
  167. <div class="progress-bar">
  168. <div class="progress-fill" style="width: 0%"></div>
  169. </div>
  170. <div class="progress-text">0% Complete</div>
  171. </div>
  172. <div class="optimization-stats">
  173. <div class="stat">
  174. <span class="stat-label">Images Processed:</span>
  175. <span class="stat-value" id="processed-count">0</span>
  176. </div>
  177. <div class="stat">
  178. <span class="stat-label">Space Saved:</span>
  179. <span class="stat-value" id="space-saved">0 MB</span>
  180. </div>
  181. </div>
  182. <div class="optimization-log">
  183. <h4>Optimization Log</h4>
  184. <div id="log-content" class="log-content"></div>
  185. </div>
  186. </div>
  187. <script>
  188. document.addEventListener('DOMContentLoaded', function() {
  189. const startBtn = document.getElementById('start-optimization');
  190. const stopBtn = document.getElementById('stop-optimization');
  191. const progressBar = document.querySelector('.progress-bar');
  192. const progressFill = document.querySelector('.progress-fill');
  193. const progressText = document.querySelector('.progress-text');
  194. const processedCount = document.getElementById('processed-count');
  195. const spaceSaved = document.getElementById('space-saved');
  196. const logContent = document.getElementById('log-content');
  197. let isOptimizing = false;
  198. let processedImages = 0;
  199. let totalSpaceSaved = 0;
  200. startBtn.addEventListener('click', function() {
  201. if (isOptimizing) return;
  202. isOptimizing = true;
  203. startBtn.disabled = true;
  204. stopBtn.disabled = false;
  205. progressBar.style.display = 'block';
  206. // Simulate optimization process
  207. simulateOptimization();
  208. });
  209. stopBtn.addEventListener('click', function() {
  210. isOptimizing = false;
  211. startBtn.disabled = false;
  212. stopBtn.disabled = true;
  213. progressBar.style.display = 'none';
  214. });
  215. function simulateOptimization() {
  216. if (!isOptimizing) return;
  217. // Simulate processing images
  218. processedImages++;
  219. totalSpaceSaved += Math.random() * 0.5; // Random space saved
  220. // Update UI
  221. processedCount.textContent = processedImages;
  222. spaceSaved.textContent = totalSpaceSaved.toFixed(2) + ' MB';
  223. // Update progress
  224. const progress = Math.min((processedImages / 100) * 100, 100);
  225. progressFill.style.width = progress + '%';
  226. progressText.textContent = Math.round(progress) + '% Complete';
  227. // Add log entry
  228. const logEntry = document.createElement('div');
  229. logEntry.textContent = `Processed image ${processedImages}: Saved ${(Math.random() * 0.5).toFixed(2)} MB`;
  230. logContent.appendChild(logEntry);
  231. logContent.scrollTop = logContent.scrollHeight;
  232. if (processedImages < 100 && isOptimizing) {
  233. setTimeout(simulateOptimization, 100);
  234. } else {
  235. // Optimization complete
  236. isOptimizing = false;
  237. startBtn.disabled = false;
  238. stopBtn.disabled = true;
  239. progressBar.style.display = 'none';
  240. }
  241. }
  242. });
  243. </script>
  244. HTML
  245. end
  246. end
  247. # Image optimization stats tag
  248. 1 class OptimizationStatsTag < Liquid::Tag
  249. 1 def initialize(tag_name, markup, options)
  250. super
  251. end
  252. 1 def render(context)
  253. generate_optimization_stats
  254. end
  255. 1 private
  256. 1 def generate_optimization_stats
  257. stats = calculate_optimization_stats
  258. <<~HTML
  259. <div class="optimization-stats">
  260. <h4>Image Optimization Statistics</h4>
  261. <div class="stats-grid">
  262. <div class="stat-item">
  263. <span class="stat-number">#{stats[:total_images]}</span>
  264. <span class="stat-label">Total Images</span>
  265. </div>
  266. <div class="stat-item">
  267. <span class="stat-number">#{stats[:optimized_images]}</span>
  268. <span class="stat-label">Optimized</span>
  269. </div>
  270. <div class="stat-item">
  271. <span class="stat-number">#{stats[:webp_variants]}</span>
  272. <span class="stat-label">WebP Variants</span>
  273. </div>
  274. <div class="stat-item">
  275. <span class="stat-number">#{stats[:avif_variants]}</span>
  276. <span class="stat-label">AVIF Variants</span>
  277. </div>
  278. <div class="stat-item">
  279. <span class="stat-number">#{stats[:space_saved]} MB</span>
  280. <span class="stat-label">Space Saved</span>
  281. </div>
  282. <div class="stat-item">
  283. <span class="stat-number">#{stats[:optimization_percentage]}%</span>
  284. <span class="stat-label">Optimization Rate</span>
  285. </div>
  286. </div>
  287. </div>
  288. HTML
  289. end
  290. 1 def calculate_optimization_stats
  291. total_images = Upload.joins(:file_attachment).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] }).count
  292. optimized_images = Upload.where.not(variants: [nil, {}]).joins(:file_attachment).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] }).count
  293. webp_variants = Upload.where("variants LIKE ?", '%webp%').count
  294. avif_variants = Upload.where("variants LIKE ?", '%avif%').count
  295. # Calculate space saved (simplified)
  296. space_saved = (total_images * 0.3).round(1) # Assume 30% average savings
  297. then: 0 else: 0 optimization_percentage = total_images > 0 ? ((optimized_images.to_f / total_images) * 100).round(1) : 0
  298. {
  299. total_images: total_images,
  300. optimized_images: optimized_images,
  301. webp_variants: webp_variants,
  302. avif_variants: avif_variants,
  303. space_saved: space_saved,
  304. optimization_percentage: optimization_percentage
  305. }
  306. end
  307. end
  308. # Register the Liquid tags
  309. 1 Liquid::Template.register_tag('image_optimized', ImageOptimizedTag)
  310. 1 Liquid::Template.register_tag('background_image_optimized', BackgroundImageOptimizedTag)
  311. 1 Liquid::Template.register_tag('bulk_optimize', BulkOptimizeTag)
  312. 1 Liquid::Template.register_tag('optimization_stats', OptimizationStatsTag)

lib/railspress/newsletter_shortcodes.rb

0.0% lines covered

100.0% branches covered

319 relevant lines. 0 lines covered and 319 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Railspress
  3. # Newsletter-specific shortcodes
  4. class NewsletterShortcodes
  5. def self.register_all
  6. # [newsletter] - Basic newsletter signup form
  7. Railspress::ShortcodeProcessor.register('newsletter') do |attrs, content|
  8. render_newsletter_form(attrs, content)
  9. end
  10. # [newsletter_inline] - Inline newsletter form (horizontal)
  11. Railspress::ShortcodeProcessor.register('newsletter_inline') do |attrs, content|
  12. render_inline_form(attrs, content)
  13. end
  14. # [newsletter_popup] - Popup newsletter form
  15. Railspress::ShortcodeProcessor.register('newsletter_popup') do |attrs, content|
  16. render_popup_form(attrs, content)
  17. end
  18. # [newsletter_count] - Display subscriber count
  19. Railspress::ShortcodeProcessor.register('newsletter_count') do |attrs, content|
  20. count = Subscriber.confirmed.count
  21. "<span class=\"newsletter-count\">#{number_with_delimiter(count)}</span>"
  22. end
  23. # [newsletter_stats] - Display newsletter statistics
  24. Railspress::ShortcodeProcessor.register('newsletter_stats') do |attrs, content|
  25. render_stats(attrs)
  26. end
  27. end
  28. private
  29. def self.render_newsletter_form(attrs, content)
  30. title = attrs['title'] || 'Subscribe to our Newsletter'
  31. description = attrs['description'] || 'Get the latest updates delivered to your inbox.'
  32. button_text = attrs['button'] || 'Subscribe'
  33. source = attrs['source'] || 'shortcode'
  34. style = attrs['style'] || 'default'
  35. <<~HTML
  36. <div class="newsletter-form #{style}-style" data-controller="newsletter-form">
  37. <div class="newsletter-header">
  38. <h3 class="newsletter-title">#{title}</h3>
  39. <p class="newsletter-description">#{description}</p>
  40. </div>
  41. <form action="/subscribe" method="post" class="newsletter-form-fields" data-action="submit->newsletter-form#submit">
  42. <input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
  43. <input type="hidden" name="source" value="#{source}">
  44. <div class="form-group">
  45. <input type="email"
  46. name="subscriber[email]"
  47. placeholder="Enter your email"
  48. required
  49. class="newsletter-email-input">
  50. </div>
  51. <div class="form-group">
  52. <input type="text"
  53. name="subscriber[name]"
  54. placeholder="Your name (optional)"
  55. class="newsletter-name-input">
  56. </div>
  57. <button type="submit" class="newsletter-submit-btn">
  58. #{button_text}
  59. </button>
  60. <p class="newsletter-privacy">
  61. We respect your privacy. Unsubscribe at any time.
  62. </p>
  63. </form>
  64. </div>
  65. <style>
  66. .newsletter-form {
  67. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  68. color: white;
  69. padding: 2rem;
  70. border-radius: 12px;
  71. max-width: 500px;
  72. margin: 2rem auto;
  73. }
  74. .newsletter-form.minimal-style {
  75. background: #f9fafb;
  76. color: #1f2937;
  77. border: 1px solid #e5e7eb;
  78. }
  79. .newsletter-title {
  80. font-size: 1.5rem;
  81. font-weight: bold;
  82. margin-bottom: 0.5rem;
  83. }
  84. .newsletter-description {
  85. opacity: 0.9;
  86. margin-bottom: 1.5rem;
  87. }
  88. .newsletter-email-input,
  89. .newsletter-name-input {
  90. width: 100%;
  91. padding: 0.75rem 1rem;
  92. border: 1px solid rgba(255,255,255,0.3);
  93. border-radius: 8px;
  94. font-size: 1rem;
  95. margin-bottom: 1rem;
  96. }
  97. .newsletter-form.minimal-style input {
  98. border: 1px solid #e5e7eb;
  99. background: white;
  100. }
  101. .newsletter-submit-btn {
  102. width: 100%;
  103. padding: 0.75rem 1rem;
  104. background: white;
  105. color: #667eea;
  106. border: none;
  107. border-radius: 8px;
  108. font-weight: 600;
  109. font-size: 1rem;
  110. cursor: pointer;
  111. transition: transform 0.2s;
  112. }
  113. .newsletter-form.minimal-style .newsletter-submit-btn {
  114. background: #667eea;
  115. color: white;
  116. }
  117. .newsletter-submit-btn:hover {
  118. transform: translateY(-2px);
  119. }
  120. .newsletter-privacy {
  121. margin-top: 1rem;
  122. font-size: 0.875rem;
  123. opacity: 0.7;
  124. text-align: center;
  125. }
  126. </style>
  127. HTML
  128. end
  129. def self.render_inline_form(attrs, content)
  130. button_text = attrs['button'] || 'Subscribe'
  131. source = attrs['source'] || 'inline_shortcode'
  132. placeholder = attrs['placeholder'] || 'Enter your email'
  133. <<~HTML
  134. <div class="newsletter-inline-form" data-controller="newsletter-form">
  135. <form action="/subscribe" method="post" class="inline-form" data-action="submit->newsletter-form#submit">
  136. <input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
  137. <input type="hidden" name="source" value="#{source}">
  138. <div class="inline-form-wrapper">
  139. <input type="email"
  140. name="subscriber[email]"
  141. placeholder="#{placeholder}"
  142. required
  143. class="inline-email-input">
  144. <button type="submit" class="inline-submit-btn">
  145. #{button_text}
  146. </button>
  147. </div>
  148. </form>
  149. </div>
  150. <style>
  151. .newsletter-inline-form {
  152. margin: 2rem 0;
  153. }
  154. .inline-form-wrapper {
  155. display: flex;
  156. gap: 0.5rem;
  157. max-width: 500px;
  158. margin: 0 auto;
  159. }
  160. .inline-email-input {
  161. flex: 1;
  162. padding: 0.75rem 1rem;
  163. border: 1px solid #e5e7eb;
  164. border-radius: 8px;
  165. font-size: 1rem;
  166. }
  167. .inline-submit-btn {
  168. padding: 0.75rem 2rem;
  169. background: #667eea;
  170. color: white;
  171. border: none;
  172. border-radius: 8px;
  173. font-weight: 600;
  174. cursor: pointer;
  175. white-space: nowrap;
  176. }
  177. .inline-submit-btn:hover {
  178. background: #5568d3;
  179. }
  180. </style>
  181. HTML
  182. end
  183. def self.render_popup_form(attrs, content)
  184. # Popup newsletter form (requires JavaScript)
  185. button_text = attrs['button'] || 'Subscribe'
  186. trigger_text = attrs['trigger'] || 'Join Newsletter'
  187. <<~HTML
  188. <button class="newsletter-popup-trigger" data-action="click->newsletter-popup#open">
  189. #{trigger_text}
  190. </button>
  191. <div class="newsletter-popup-overlay" data-newsletter-popup-target="overlay" style="display: none;">
  192. <div class="newsletter-popup-modal">
  193. <button class="newsletter-popup-close" data-action="click->newsletter-popup#close">×</button>
  194. <h3 class="newsletter-popup-title">Subscribe to our Newsletter</h3>
  195. <p class="newsletter-popup-description">Get the latest updates delivered to your inbox.</p>
  196. <form action="/subscribe" method="post" data-action="submit->newsletter-popup#submit">
  197. <input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
  198. <input type="hidden" name="source" value="popup">
  199. <input type="email" name="subscriber[email]" placeholder="Email" required class="newsletter-popup-input">
  200. <input type="text" name="subscriber[name]" placeholder="Name (optional)" class="newsletter-popup-input">
  201. <button type="submit" class="newsletter-popup-submit">#{button_text}</button>
  202. </form>
  203. </div>
  204. </div>
  205. <style>
  206. .newsletter-popup-trigger {
  207. padding: 0.75rem 1.5rem;
  208. background: #667eea;
  209. color: white;
  210. border: none;
  211. border-radius: 8px;
  212. font-weight: 600;
  213. cursor: pointer;
  214. }
  215. .newsletter-popup-overlay {
  216. position: fixed;
  217. top: 0;
  218. left: 0;
  219. right: 0;
  220. bottom: 0;
  221. background: rgba(0,0,0,0.7);
  222. display: flex;
  223. align-items: center;
  224. justify-center;
  225. z-index: 9999;
  226. }
  227. .newsletter-popup-modal {
  228. background: white;
  229. padding: 2rem;
  230. border-radius: 12px;
  231. max-width: 500px;
  232. width: 90%;
  233. position: relative;
  234. }
  235. .newsletter-popup-close {
  236. position: absolute;
  237. top: 1rem;
  238. right: 1rem;
  239. background: none;
  240. border: none;
  241. font-size: 2rem;
  242. cursor: pointer;
  243. color: #9ca3af;
  244. }
  245. .newsletter-popup-title {
  246. font-size: 1.5rem;
  247. font-weight: bold;
  248. margin-bottom: 0.5rem;
  249. color: #1f2937;
  250. }
  251. .newsletter-popup-description {
  252. color: #6b7280;
  253. margin-bottom: 1.5rem;
  254. }
  255. .newsletter-popup-input {
  256. width: 100%;
  257. padding: 0.75rem 1rem;
  258. border: 1px solid #e5e7eb;
  259. border-radius: 8px;
  260. margin-bottom: 1rem;
  261. }
  262. .newsletter-popup-submit {
  263. width: 100%;
  264. padding: 0.75rem;
  265. background: #667eea;
  266. color: white;
  267. border: none;
  268. border-radius: 8px;
  269. font-weight: 600;
  270. cursor: pointer;
  271. }
  272. </style>
  273. HTML
  274. end
  275. def self.render_stats(attrs)
  276. stats = Subscriber.stats
  277. <<~HTML
  278. <div class="newsletter-stats">
  279. <div class="stat-grid">
  280. <div class="stat-card">
  281. <div class="stat-value">#{number_with_delimiter(stats[:total])}</div>
  282. <div class="stat-label">Total Subscribers</div>
  283. </div>
  284. <div class="stat-card">
  285. <div class="stat-value">#{number_with_delimiter(stats[:confirmed])}</div>
  286. <div class="stat-label">Confirmed</div>
  287. </div>
  288. <div class="stat-card">
  289. <div class="stat-value">#{stats[:confirmation_rate]}%</div>
  290. <div class="stat-label">Confirmation Rate</div>
  291. </div>
  292. </div>
  293. </div>
  294. <style>
  295. .newsletter-stats {
  296. margin: 2rem 0;
  297. }
  298. .stat-grid {
  299. display: grid;
  300. grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  301. gap: 1rem;
  302. }
  303. .stat-card {
  304. background: #f9fafb;
  305. padding: 1.5rem;
  306. border-radius: 8px;
  307. text-align: center;
  308. }
  309. .stat-value {
  310. font-size: 2rem;
  311. font-weight: bold;
  312. color: #667eea;
  313. }
  314. .stat-label {
  315. font-size: 0.875rem;
  316. color: #6b7280;
  317. margin-top: 0.5rem;
  318. }
  319. </style>
  320. HTML
  321. end
  322. def self.form_authenticity_token
  323. # This would need to be passed from the view context
  324. ''
  325. end
  326. def self.number_with_delimiter(number)
  327. number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
  328. end
  329. end
  330. end
  331. # Register shortcodes on load
  332. Railspress::NewsletterShortcodes.register_all

lib/railspress/plugin_api/channels.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module PluginApi
  3. # Plugin API for accessing channels and overrides
  4. module Channels
  5. extend ActiveSupport::Concern
  6. # Get all available channels
  7. def self.all_channels
  8. Channel.all
  9. end
  10. # Get active channels only
  11. def self.active_channels
  12. Channel.active
  13. end
  14. # Find channel by slug
  15. def self.find_channel(slug)
  16. Channel.find_by(slug: slug)
  17. end
  18. # Get channel for specific device type
  19. def self.channel_for_device(device_type)
  20. case device_type.to_s
  21. when 'mobile', 'tablet'
  22. Channel.find_by(slug: 'mobile')
  23. when 'smart_tv', 'tv'
  24. Channel.find_by(slug: 'smarttv')
  25. when 'email'
  26. Channel.find_by(slug: 'newsletter')
  27. else
  28. Channel.find_by(slug: 'web')
  29. end
  30. end
  31. # Auto-detect channel from user agent
  32. def self.auto_detect_channel(user_agent)
  33. device_type = detect_device_type(user_agent)
  34. channel_for_device(device_type)
  35. end
  36. # Get content with channel overrides applied
  37. def self.content_with_overrides(content, channel_slug, resource_type, resource_id)
  38. channel = find_channel(channel_slug)
  39. return content unless channel
  40. if content.respond_to?(:apply_channel_settings)
  41. content.apply_channel_settings(content, user_agent)
  42. else
  43. # Apply basic channel overrides
  44. channel.apply_overrides_to_data(content, resource_type, resource_id)
  45. end
  46. end
  47. # Get channel-specific settings
  48. def self.channel_settings(channel_slug)
  49. channel = find_channel(channel_slug)
  50. return {} unless channel
  51. channel.settings.merge(
  52. 'channel_name' => channel.name,
  53. 'channel_slug' => channel.slug,
  54. 'domain' => channel.domain,
  55. 'locale' => channel.locale
  56. )
  57. end
  58. # Check if content is excluded from channel
  59. def self.is_excluded?(resource_type, resource_id, channel_slug)
  60. channel = find_channel(channel_slug)
  61. return false unless channel
  62. channel.excluded?(resource_type, resource_id)
  63. end
  64. # Get all overrides for a channel
  65. def self.channel_overrides(channel_slug)
  66. channel = find_channel(channel_slug)
  67. return [] unless channel
  68. channel.channel_overrides.includes(:resource)
  69. end
  70. # Get overrides for specific resource
  71. def self.resource_overrides(resource_type, resource_id, channel_slug)
  72. channel = find_channel(channel_slug)
  73. return [] unless channel
  74. channel.overrides_for(resource_type, resource_id)
  75. end
  76. # Create a new channel override
  77. def self.create_override(channel_slug, resource_type, resource_id, path, data, kind = 'override')
  78. channel = find_channel(channel_slug)
  79. return nil unless channel
  80. channel.channel_overrides.create!(
  81. resource_type: resource_type,
  82. resource_id: resource_id,
  83. path: path,
  84. data: data,
  85. kind: kind,
  86. enabled: true
  87. )
  88. end
  89. # Update channel settings
  90. def self.update_channel_settings(channel_slug, settings)
  91. channel = find_channel(channel_slug)
  92. return false unless channel
  93. channel.update!(settings: channel.settings.merge(settings))
  94. end
  95. private
  96. def self.detect_device_type(user_agent)
  97. return :email if user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
  98. return :mobile if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
  99. return :tablet if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
  100. return :smart_tv if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
  101. :desktop
  102. end
  103. end
  104. end
  105. end

lib/railspress/plugin_base.rb

27.55% lines covered

0.0% branches covered

432 relevant lines. 119 lines covered and 313 lines missed.
105 total branches, 0 branches covered and 105 branches missed.
    
  1. 1 module Railspress
  2. 1 class PluginBase
  3. 1 class << self
  4. # DSL for plugin metadata (WordPress-style)
  5. 1 def plugin_name(name = nil)
  6. then: 0 else: 0 @plugin_name = name if name
  7. @plugin_name
  8. end
  9. 1 def plugin_version(version = nil)
  10. then: 0 else: 0 @plugin_version = version if version
  11. @plugin_version
  12. end
  13. 1 def plugin_description(description = nil)
  14. then: 0 else: 0 @plugin_description = description if description
  15. @plugin_description
  16. end
  17. 1 def plugin_author(author = nil)
  18. then: 0 else: 0 @plugin_author = author if author
  19. @plugin_author
  20. end
  21. 1 def plugin_url(url = nil)
  22. then: 0 else: 0 @plugin_url = url if url
  23. @plugin_url
  24. end
  25. 1 def plugin_license(license = nil)
  26. then: 0 else: 0 @plugin_license = license if license
  27. @plugin_license
  28. end
  29. # DSL method for defining settings schema
  30. 1 def settings_schema(&block)
  31. @settings_schema_block = block
  32. end
  33. end
  34. 1 attr_reader :settings_schema, :admin_pages, :routes_block
  35. 1 def initialize
  36. @settings_schema = []
  37. @admin_pages = []
  38. @routes_block = nil
  39. @admin_routes_block = nil
  40. @frontend_routes_block = nil
  41. # Enhanced plugin features
  42. @webhooks = []
  43. @events = []
  44. @middleware = []
  45. @assets = []
  46. @commands = []
  47. @validators = []
  48. @api_endpoints = []
  49. @theme_templates = []
  50. @theme_assets = []
  51. @theme_settings = []
  52. # Copy class-level metadata to instance
  53. @name = self.class.plugin_name
  54. @version = self.class.plugin_version
  55. @description = self.class.plugin_description
  56. @author = self.class.plugin_author
  57. @url = self.class.plugin_url
  58. @license = self.class.plugin_license
  59. # Execute settings schema block if defined
  60. then: 0 else: 0 if self.class.instance_variable_get(:@settings_schema_block)
  61. instance_eval(&self.class.instance_variable_get(:@settings_schema_block))
  62. end
  63. then: 0 else: 0 setup if respond_to?(:setup, true)
  64. end
  65. # Accessors for metadata
  66. 1 def name
  67. @name || self.class.name.demodulize
  68. end
  69. 1 def version
  70. @version || '1.0.0'
  71. end
  72. 1 def description
  73. @description || ''
  74. end
  75. 1 def author
  76. @author || ''
  77. end
  78. # Activation hook - called when plugin is activated
  79. 1 def activate
  80. log("Activating #{name} v#{version}")
  81. # Override in subclass
  82. end
  83. # Deactivation hook - called when plugin is deactivated
  84. 1 def deactivate
  85. log("Deactivating #{name}")
  86. # Override in subclass
  87. end
  88. # Uninstall hook - called when plugin is deleted
  89. 1 def uninstall
  90. log("Uninstalling #{name}")
  91. # Remove all plugin settings
  92. PluginSetting.where(plugin_name: plugin_identifier).destroy_all
  93. # Override in subclass for additional cleanup
  94. end
  95. # ========================================
  96. # CHANNEL INTEGRATION
  97. # ========================================
  98. # Get all available channels
  99. 1 def all_channels
  100. Channel.all
  101. end
  102. # Get active channels only
  103. 1 def active_channels
  104. Channel.active
  105. end
  106. # Find channel by slug
  107. 1 def find_channel(slug)
  108. Channel.find_by(slug: slug)
  109. end
  110. # Get channel for specific device type
  111. 1 def channel_for_device(device_type)
  112. case device_type.to_s
  113. when: 0 when 'mobile', 'tablet'
  114. Channel.find_by(slug: 'mobile')
  115. when: 0 when 'smart_tv', 'tv'
  116. Channel.find_by(slug: 'smarttv')
  117. when: 0 when 'email'
  118. Channel.find_by(slug: 'newsletter')
  119. else: 0 else
  120. Channel.find_by(slug: 'web')
  121. end
  122. end
  123. # Auto-detect channel from user agent
  124. 1 def auto_detect_channel(user_agent)
  125. device_type = detect_device_type(user_agent)
  126. channel_for_device(device_type)
  127. end
  128. # Get content with channel overrides applied
  129. 1 def content_with_overrides(content, channel_slug, resource_type, resource_id)
  130. channel = find_channel(channel_slug)
  131. else: 0 then: 0 return content unless channel
  132. then: 0 if content.respond_to?(:apply_channel_settings)
  133. content.apply_channel_settings(content, user_agent)
  134. else
  135. else: 0 # Apply basic channel overrides
  136. channel.apply_overrides_to_data(content, resource_type, resource_id)
  137. end
  138. end
  139. # Get channel-specific settings
  140. 1 def channel_settings(channel_slug)
  141. channel = find_channel(channel_slug)
  142. else: 0 then: 0 return {} unless channel
  143. channel.settings.merge(
  144. 'channel_name' => channel.name,
  145. 'channel_slug' => channel.slug,
  146. 'domain' => channel.domain,
  147. 'locale' => channel.locale
  148. )
  149. end
  150. # Check if content is excluded from channel
  151. 1 def is_excluded?(resource_type, resource_id, channel_slug)
  152. channel = find_channel(channel_slug)
  153. else: 0 then: 0 return false unless channel
  154. channel.excluded?(resource_type, resource_id)
  155. end
  156. # Get all overrides for a channel
  157. 1 def channel_overrides(channel_slug)
  158. channel = find_channel(channel_slug)
  159. else: 0 then: 0 return [] unless channel
  160. channel.channel_overrides.includes(:resource)
  161. end
  162. # Get overrides for specific resource
  163. 1 def resource_overrides(resource_type, resource_id, channel_slug)
  164. channel = find_channel(channel_slug)
  165. else: 0 then: 0 return [] unless channel
  166. channel.overrides_for(resource_type, resource_id)
  167. end
  168. # Create a new channel override
  169. 1 def create_override(channel_slug, resource_type, resource_id, path, data, kind = 'override')
  170. channel = find_channel(channel_slug)
  171. else: 0 then: 0 return nil unless channel
  172. channel.channel_overrides.create!(
  173. resource_type: resource_type,
  174. resource_id: resource_id,
  175. path: path,
  176. data: data,
  177. kind: kind,
  178. enabled: true
  179. )
  180. end
  181. # Update channel settings
  182. 1 def update_channel_settings(channel_slug, settings)
  183. channel = find_channel(channel_slug)
  184. else: 0 then: 0 return false unless channel
  185. channel.update!(settings: channel.settings.merge(settings))
  186. end
  187. # Process content for specific channel
  188. 1 def process_content_for_channel(content, channel_slug, options = {})
  189. settings = channel_settings(channel_slug)
  190. processed_content = content.dup
  191. # Apply device-specific optimizations
  192. case settings['device_type']
  193. when: 0 when 'mobile', 'tablet'
  194. processed_content = optimize_for_mobile(processed_content, settings)
  195. when: 0 when 'smart_tv'
  196. processed_content = optimize_for_tv(processed_content, settings)
  197. when: 0 when 'email'
  198. processed_content = optimize_for_email(processed_content, settings)
  199. else: 0 else
  200. processed_content = optimize_for_desktop(processed_content, settings)
  201. end
  202. # Apply channel overrides
  203. then: 0 else: 0 if options[:apply_overrides] != false
  204. processed_content = content_with_overrides(processed_content, channel_slug, options[:resource_type], options[:resource_id])
  205. end
  206. processed_content
  207. end
  208. # Distribute content to all channels
  209. 1 def distribute_content_to_channels(content, options = {})
  210. results = {}
  211. Channel.active.each do |channel|
  212. results[channel.slug] = process_content_for_channel(
  213. content,
  214. channel.slug,
  215. options.merge(
  216. resource_type: options[:resource_type],
  217. resource_id: options[:resource_id]
  218. )
  219. )
  220. end
  221. results
  222. end
  223. # Check if plugin has settings
  224. 1 def has_settings?
  225. @settings_schema.any?
  226. end
  227. # Get all admin pages for this plugin
  228. 1 def admin_pages
  229. @admin_pages
  230. end
  231. # Check if plugin has admin pages
  232. 1 def has_admin_pages?
  233. @admin_pages.any?
  234. end
  235. # Get plugin setting value
  236. 1 def get_setting(key, default = nil)
  237. setting = PluginSetting.find_by(plugin_name: plugin_identifier, key: key.to_s)
  238. then: 0 else: 0 return parse_setting_value(setting.value, setting.setting_type) if setting
  239. # Return default from schema
  240. schema_setting = @settings_schema.find { |s| s[:key] == key.to_s }
  241. then: 0 else: 0 schema_setting&.dig(:default) || default
  242. end
  243. # Set plugin setting value
  244. 1 def set_setting(key, value)
  245. # Determine setting type from schema
  246. schema = @settings_schema.find { |s| s[:key] == key.to_s }
  247. then: 0 else: 0 setting_type = schema ? map_schema_type_to_db_type(schema[:type]) : 'string'
  248. PluginSetting.find_or_create_by!(
  249. plugin_name: plugin_identifier,
  250. key: key.to_s
  251. ) do |setting|
  252. setting.value = value.to_s
  253. setting.setting_type = setting_type
  254. end.tap do |setting|
  255. setting.update(value: value.to_s, setting_type: setting_type)
  256. end
  257. end
  258. # Get all plugin settings as hash
  259. 1 def get_all_settings
  260. hash = {}
  261. @settings_schema.each do |schema|
  262. hash[schema[:key]] = get_setting(schema[:key])
  263. end
  264. hash
  265. end
  266. # Update multiple settings at once
  267. 1 def update_settings(settings_hash)
  268. settings_hash.each do |key, value|
  269. set_setting(key, value)
  270. end
  271. end
  272. 1 private
  273. 1 def detect_device_type(user_agent)
  274. then: 0 else: 0 return :email if user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
  275. then: 0 else: 0 return :mobile if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
  276. then: 0 else: 0 return :tablet if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
  277. then: 0 else: 0 return :smart_tv if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
  278. :desktop
  279. end
  280. 1 def optimize_for_mobile(content, settings)
  281. then: 0 else: 0 content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
  282. .gsub(/width="\d+"/i, '') # Remove width attributes
  283. .gsub(/height="\d+"/i, '') # Remove height attributes
  284. .gsub(/<script[^>]*>.*?<\/script>/mi, '') if settings['minimal_js']
  285. end
  286. 1 def optimize_for_tv(content, settings)
  287. content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
  288. .gsub(/font-size:\s*\d+px/i, "font-size: #{settings['font_size'] || '24px'}") # Larger text
  289. end
  290. 1 def optimize_for_email(content, settings)
  291. content.gsub(/style="[^"]*"/i, '') # Remove inline styles
  292. .gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
  293. .gsub(/<\/div>/i, '</td></tr></table>')
  294. end
  295. 1 def optimize_for_desktop(content, settings)
  296. content
  297. end
  298. # ========================================
  299. # SETTINGS SYSTEM
  300. # ========================================
  301. # Define plugin setting with schema
  302. # Example:
  303. # define_setting :api_key,
  304. # type: 'string',
  305. # default: '',
  306. # label: 'API Key',
  307. # description: 'Your API key',
  308. # required: true,
  309. # placeholder: 'sk-...'
  310. 1 def define_setting(key, options = {})
  311. @settings_schema << {
  312. key: key.to_s,
  313. type: options[:type] || 'string',
  314. default: options[:default],
  315. label: options[:label] || key.to_s.titleize,
  316. description: options[:description],
  317. required: options[:required] || false,
  318. options: options[:options], # For select/radio types
  319. placeholder: options[:placeholder],
  320. min: options[:min],
  321. max: options[:max],
  322. rows: options[:rows], # For textarea
  323. group: options[:group] # For organizing settings
  324. }
  325. end
  326. # ========================================
  327. # SETTINGS SCHEMA DSL METHODS
  328. # ========================================
  329. # Define a settings section
  330. 1 def section(title, options = {}, &block)
  331. # For now, we'll just execute the block
  332. # In a more advanced implementation, we could group settings by section
  333. then: 0 else: 0 instance_eval(&block) if block_given?
  334. end
  335. # Define a text input setting
  336. 1 def text(key, label, options = {})
  337. define_setting(key, {
  338. type: 'text',
  339. label: label,
  340. description: options[:description],
  341. required: options[:required] || false,
  342. placeholder: options[:placeholder],
  343. default: options[:default]
  344. })
  345. end
  346. # Define a textarea setting
  347. 1 def textarea(key, label, options = {})
  348. define_setting(key, {
  349. type: 'textarea',
  350. label: label,
  351. description: options[:description],
  352. required: options[:required] || false,
  353. placeholder: options[:placeholder],
  354. default: options[:default],
  355. rows: options[:rows] || 4
  356. })
  357. end
  358. # Define a select dropdown setting
  359. 1 def select(key, label, options_array, options = {})
  360. define_setting(key, {
  361. type: 'select',
  362. label: label,
  363. description: options[:description],
  364. required: options[:required] || false,
  365. default: options[:default],
  366. options: options_array
  367. })
  368. end
  369. # Define a checkbox setting
  370. 1 def checkbox(key, label, options = {})
  371. define_setting(key, {
  372. type: 'checkbox',
  373. label: label,
  374. description: options[:description],
  375. required: options[:required] || false,
  376. default: options[:default] || false
  377. })
  378. end
  379. # Define a number input setting
  380. 1 def number(key, label, options = {})
  381. define_setting(key, {
  382. type: 'number',
  383. label: label,
  384. description: options[:description],
  385. required: options[:required] || false,
  386. default: options[:default],
  387. min: options[:min],
  388. max: options[:max]
  389. })
  390. end
  391. # Define a URL input setting
  392. 1 def url(key, label, options = {})
  393. define_setting(key, {
  394. type: 'url',
  395. label: label,
  396. description: options[:description],
  397. required: options[:required] || false,
  398. placeholder: options[:placeholder],
  399. default: options[:default]
  400. })
  401. end
  402. # Define a color picker setting
  403. 1 def color(key, label, options = {})
  404. define_setting(key, {
  405. type: 'color',
  406. label: label,
  407. description: options[:description],
  408. required: options[:required] || false,
  409. default: options[:default]
  410. })
  411. end
  412. # Define a radio button setting
  413. 1 def radio(key, label, options_array, options = {})
  414. define_setting(key, {
  415. type: 'radio',
  416. label: label,
  417. description: options[:description],
  418. required: options[:required] || false,
  419. default: options[:default],
  420. options: options_array
  421. })
  422. end
  423. # Register a UI block (placeholder method)
  424. 1 def register_block(block_name, options = {})
  425. # This is a placeholder for UI block registration
  426. # In a full implementation, this would register blocks for the theme system
  427. Rails.logger.info "Registered UI block: #{block_name}"
  428. end
  429. # Add an action hook (similar to WordPress add_action)
  430. 1 def add_action(hook_name, callback = nil, priority = 10, &block)
  431. then: 0 else: 0 if block_given?
  432. callback = block
  433. end
  434. then: 0 if callback
  435. Railspress::PluginSystem.add_action(hook_name, callback, priority, plugin_name)
  436. Rails.logger.info "Added action hook: #{hook_name} for plugin: #{plugin_name}"
  437. else: 0 else
  438. Rails.logger.warn "No callback provided for hook: #{hook_name}"
  439. end
  440. end
  441. # Check if setting is enabled (for boolean settings)
  442. 1 def setting_enabled?(key)
  443. value = get_setting(key)
  444. value == true || value == 'true' || value == '1'
  445. end
  446. # ========================================
  447. # ADMIN PAGES SYSTEM
  448. # ========================================
  449. # Register an admin page for this plugin
  450. # Example:
  451. # register_admin_page(
  452. # slug: 'dashboard',
  453. # title: 'My Plugin Dashboard',
  454. # menu_title: 'Dashboard',
  455. # icon: 'chart-bar',
  456. # position: 10,
  457. # parent: 'plugins' # Optional parent menu
  458. # )
  459. 1 def register_admin_page(options = {})
  460. page = {
  461. plugin: plugin_identifier,
  462. slug: options[:slug] || 'settings',
  463. path: "admin/plugins/#{plugin_identifier}/#{options[:slug] || 'settings'}",
  464. title: options[:title] || "#{name} Settings",
  465. menu_title: options[:menu_title] || name,
  466. capability: options[:capability] || 'administrator',
  467. icon: options[:icon] || 'puzzle',
  468. position: options[:position] || 100,
  469. parent: options[:parent], # 'plugins', 'tools', 'settings', or nil for top-level
  470. callback: options[:callback] # Method to call for rendering
  471. }
  472. @admin_pages << page
  473. # Store in global registry for sidebar rendering
  474. Railspress::PluginSystem.register_admin_page(plugin_identifier, page)
  475. log("Registered admin page: #{page[:path]}")
  476. end
  477. # Render default settings page
  478. 1 def render_settings_page
  479. {
  480. title: "#{name} Settings",
  481. settings: @settings_schema,
  482. current_values: get_all_settings,
  483. save_url: "/admin/plugins/#{plugin_identifier}/settings",
  484. plugin_info: metadata
  485. }
  486. end
  487. # ========================================
  488. # ROUTES SYSTEM
  489. # ========================================
  490. # Register routes for this plugin
  491. # Example:
  492. # register_routes do
  493. # get '/my-plugin/action', to: 'my_plugin#action'
  494. # namespace :admin do
  495. # resources :my_plugin
  496. # end
  497. # end
  498. 1 def register_routes(&block)
  499. @routes_block = block
  500. Railspress::PluginSystem.register_plugin_routes(plugin_identifier, block)
  501. log("Routes registered for #{name}", :debug)
  502. end
  503. # Register admin routes for this plugin
  504. 1 def register_admin_routes(&block)
  505. @admin_routes_block = block
  506. Railspress::PluginSystem.register_plugin_admin_routes(plugin_identifier, block)
  507. log("Admin routes registered for #{name}", :debug)
  508. end
  509. # Register frontend routes for this plugin
  510. 1 def register_frontend_routes(&block)
  511. @frontend_routes_block = block
  512. Railspress::PluginSystem.register_plugin_frontend_routes(plugin_identifier, block)
  513. log("Frontend routes registered for #{name}", :debug)
  514. end
  515. # Check if plugin has routes
  516. 1 def has_routes?
  517. @routes_block.present? || @admin_routes_block.present? || @frontend_routes_block.present?
  518. end
  519. # ========================================
  520. # WEBHOOK SYSTEM
  521. # ========================================
  522. # Register a webhook endpoint
  523. 1 def register_webhook(event_name, url, options = {})
  524. webhook = {
  525. event: event_name,
  526. url: url,
  527. method: options[:method] || 'POST',
  528. headers: options[:headers] || {},
  529. secret: options[:secret],
  530. retry_count: options[:retry_count] || 3,
  531. timeout: options[:timeout] || 30,
  532. active: options[:active] != false
  533. }
  534. @webhooks << webhook
  535. Railspress::PluginSystem.register_webhook(plugin_identifier, webhook)
  536. log("Registered webhook for event: #{event_name}", :debug)
  537. end
  538. # Trigger a webhook
  539. 1 def trigger_webhook(event_name, data = {})
  540. Railspress::PluginSystem.trigger_webhook(plugin_identifier, event_name, data)
  541. end
  542. # ========================================
  543. # EVENT SYSTEM
  544. # ========================================
  545. # Register an event listener
  546. 1 def on(event_name, &block)
  547. event = {
  548. name: event_name,
  549. callback: block,
  550. priority: 10
  551. }
  552. @events << event
  553. Railspress::PluginSystem.register_event_listener(plugin_identifier, event)
  554. log("Registered event listener for: #{event_name}", :debug)
  555. end
  556. # Emit an event
  557. 1 def emit(event_name, data = {})
  558. Railspress::PluginSystem.emit_event(event_name, data)
  559. end
  560. # ========================================
  561. # MIDDLEWARE SYSTEM
  562. # ========================================
  563. # Add middleware to the application
  564. 1 def add_middleware(middleware_class, *args, &block)
  565. middleware = {
  566. class: middleware_class,
  567. args: args,
  568. block: block
  569. }
  570. @middleware << middleware
  571. Railspress::PluginSystem.register_middleware(plugin_identifier, middleware)
  572. log("Registered middleware: #{middleware_class}", :debug)
  573. end
  574. # ========================================
  575. # ASSET MANAGEMENT
  576. # ========================================
  577. # Register plugin assets (CSS, JS, images)
  578. 1 def register_asset(path, type = :javascript, options = {})
  579. asset = {
  580. path: path,
  581. type: type,
  582. admin_only: options[:admin_only] || false,
  583. frontend_only: options[:frontend_only] || false,
  584. priority: options[:priority] || 10,
  585. dependencies: options[:dependencies] || []
  586. }
  587. @assets << asset
  588. Railspress::PluginSystem.register_asset(plugin_identifier, asset)
  589. log("Registered #{type} asset: #{path}", :debug)
  590. end
  591. # Register CSS asset
  592. 1 def register_stylesheet(path, options = {})
  593. register_asset(path, :stylesheet, options)
  594. end
  595. # Register JavaScript asset
  596. 1 def register_javascript(path, options = {})
  597. register_asset(path, :javascript, options)
  598. end
  599. # Register image asset
  600. 1 def register_image(path, options = {})
  601. register_asset(path, :image, options)
  602. end
  603. # ========================================
  604. # API ENDPOINTS
  605. # ========================================
  606. # Register API endpoint
  607. 1 def register_api_endpoint(method, path, controller_action, options = {})
  608. endpoint = {
  609. method: method.to_s.upcase,
  610. path: path,
  611. controller: controller_action[:controller],
  612. action: controller_action[:action],
  613. authentication: options[:authentication] || :token,
  614. rate_limit: options[:rate_limit],
  615. version: options[:version] || 'v1'
  616. }
  617. @api_endpoints << endpoint
  618. Railspress::PluginSystem.register_api_endpoint(plugin_identifier, endpoint)
  619. log("Registered API endpoint: #{method.upcase} #{path}", :debug)
  620. end
  621. # ========================================
  622. # THEME SYSTEM
  623. # ========================================
  624. # Register theme template
  625. 1 def register_theme_template(name, content, options = {})
  626. template = {
  627. name: name,
  628. content: content,
  629. type: options[:type] || :page,
  630. theme: options[:theme] || 'default',
  631. variables: options[:variables] || []
  632. }
  633. @theme_templates << template
  634. Railspress::PluginSystem.register_theme_template(plugin_identifier, template)
  635. log("Registered theme template: #{name}", :debug)
  636. end
  637. # Register theme asset
  638. 1 def register_theme_asset(path, type, options = {})
  639. asset = {
  640. path: path,
  641. type: type,
  642. theme: options[:theme] || 'default',
  643. public: options[:public] || false
  644. }
  645. @theme_assets << asset
  646. Railspress::PluginSystem.register_theme_asset(plugin_identifier, asset)
  647. log("Registered theme asset: #{path}", :debug)
  648. end
  649. # Register theme setting
  650. 1 def register_theme_setting(key, type, options = {})
  651. setting = {
  652. key: key,
  653. type: type,
  654. default: options[:default],
  655. label: options[:label],
  656. description: options[:description],
  657. theme: options[:theme] || 'default'
  658. }
  659. @theme_settings << setting
  660. Railspress::PluginSystem.register_theme_setting(plugin_identifier, setting)
  661. log("Registered theme setting: #{key}", :debug)
  662. end
  663. # ========================================
  664. # CUSTOM VALIDATORS
  665. # ========================================
  666. # Register custom validator
  667. 1 def register_validator(name, &block)
  668. validator = {
  669. name: name,
  670. block: block
  671. }
  672. @validators << validator
  673. Railspress::PluginSystem.register_validator(plugin_identifier, validator)
  674. log("Registered custom validator: #{name}", :debug)
  675. end
  676. # ========================================
  677. # CUSTOM COMMANDS
  678. # ========================================
  679. # Register custom rake task
  680. 1 def register_command(name, description, &block)
  681. command = {
  682. name: name,
  683. description: description,
  684. block: block
  685. }
  686. @commands << command
  687. Railspress::PluginSystem.register_command(plugin_identifier, command)
  688. log("Registered custom command: #{name}", :debug)
  689. end
  690. # ========================================
  691. # CACHE SYSTEM
  692. # ========================================
  693. # Cache data with plugin-specific key
  694. 1 def cache(key, data = nil, expires_in: 1.hour)
  695. cache_key = "#{plugin_identifier}:#{key}"
  696. then: 0 if data
  697. Rails.cache.write(cache_key, data, expires_in: expires_in)
  698. data
  699. else: 0 else
  700. Rails.cache.read(cache_key)
  701. end
  702. end
  703. # Clear plugin cache
  704. 1 def clear_cache(pattern = nil)
  705. then: 0 if pattern
  706. Rails.cache.delete_matched("#{plugin_identifier}:#{pattern}")
  707. else: 0 else
  708. Rails.cache.delete_matched("#{plugin_identifier}:*")
  709. end
  710. end
  711. # ========================================
  712. # NOTIFICATION SYSTEM
  713. # ========================================
  714. # Send notification to admin users
  715. 1 def notify_admin(message, type = :info, options = {})
  716. Railspress::PluginSystem.notify_admin(plugin_identifier, message, type, options)
  717. end
  718. # Send notification to specific user
  719. 1 def notify_user(user_id, message, type = :info, options = {})
  720. Railspress::PluginSystem.notify_user(plugin_identifier, user_id, message, type, options)
  721. end
  722. # ========================================
  723. # SCHEDULER SYSTEM
  724. # ========================================
  725. # Schedule a recurring task
  726. 1 def schedule_task(name, cron_expression, &block)
  727. task = {
  728. name: name,
  729. cron: cron_expression,
  730. block: block
  731. }
  732. Railspress::PluginSystem.schedule_task(plugin_identifier, task)
  733. log("Scheduled task: #{name} (#{cron_expression})", :debug)
  734. end
  735. # ========================================
  736. # DATABASE HELPERS
  737. # ========================================
  738. # Create table for plugin
  739. 1 def create_table(table_name, &block)
  740. migration = Railspress::PluginSystem.create_plugin_migration(plugin_identifier, table_name, &block)
  741. log("Created table migration: #{table_name}", :debug)
  742. migration
  743. end
  744. # Add column to existing table
  745. 1 def add_column(table_name, column_name, type, options = {})
  746. Railspress::PluginSystem.add_plugin_column(plugin_identifier, table_name, column_name, type, options)
  747. log("Added column: #{table_name}.#{column_name}", :debug)
  748. end
  749. # ========================================
  750. # UTILITY METHODS
  751. # ========================================
  752. # Get plugin root path
  753. 1 def plugin_path
  754. @plugin_path ||= Rails.root.join('lib', 'plugins', plugin_identifier)
  755. end
  756. # Get plugin public URL
  757. 1 def plugin_url(path = '')
  758. "/plugins/#{plugin_identifier}/#{path}".gsub(/\/+/, '/')
  759. end
  760. # Get plugin admin URL
  761. 1 def admin_url(path = '')
  762. "/admin/#{plugin_identifier}/#{path}".gsub(/\/+/, '/')
  763. end
  764. # Check if feature is enabled
  765. 1 def feature_enabled?(feature_name)
  766. get_setting("feature_#{feature_name}", false)
  767. end
  768. # Enable/disable feature
  769. 1 def set_feature(feature_name, enabled)
  770. set_setting("feature_#{feature_name}", enabled)
  771. end
  772. # ========================================
  773. # HOOKS & FILTERS
  774. # ========================================
  775. # Add a filter hook
  776. 1 def add_filter(filter_name, method_name, priority = 10)
  777. Railspress::PluginSystem.add_filter(filter_name, -> (value, *args) {
  778. self.send(method_name, value, *args)
  779. }, priority)
  780. end
  781. # ========================================
  782. # BACKGROUND JOBS
  783. # ========================================
  784. # Create a background job for the plugin
  785. 1 def create_job(job_name, &block)
  786. job_class_name = "#{plugin_identifier.camelize}::#{job_name}"
  787. # Define job class dynamically
  788. job_class = Class.new(ApplicationJob) do
  789. queue_as :default
  790. then: 0 else: 0 class_eval(&block) if block_given?
  791. end
  792. # Set constant in plugin module
  793. plugin_module_name = plugin_identifier.camelize
  794. else: 0 then: 0 unless Object.const_defined?(plugin_module_name)
  795. Object.const_set(plugin_module_name, Module.new)
  796. end
  797. plugin_module = plugin_module_name.constantize
  798. plugin_module.const_set(job_name, job_class)
  799. log("Created job: #{job_class_name}")
  800. job_class
  801. end
  802. # Enqueue a job to run immediately
  803. 1 def enqueue_job(job_class, *args)
  804. job_class.perform_later(*args)
  805. log("Enqueued job: #{job_class.name}")
  806. end
  807. # Schedule a job to run at specific time
  808. 1 def schedule_job(job_class, run_at, *args)
  809. job_class.set(wait_until: run_at).perform_later(*args)
  810. log("Scheduled job: #{job_class.name} at #{run_at}")
  811. end
  812. # Schedule a job to run after delay
  813. 1 def schedule_job_in(job_class, delay, *args)
  814. job_class.set(wait: delay).perform_later(*args)
  815. log("Scheduled job: #{job_class.name} in #{delay}")
  816. end
  817. # Schedule recurring job (using Sidekiq-cron if available)
  818. 1 def schedule_recurring_job(job_name, cron_expression, job_class, *args)
  819. else: 0 then: 0 return unless defined?(Sidekiq::Cron)
  820. Sidekiq::Cron::Job.create(
  821. name: "#{plugin_identifier}_#{job_name}",
  822. cron: cron_expression,
  823. class: job_class.name,
  824. args: args.to_json
  825. )
  826. log("Scheduled recurring job: #{job_name} (#{cron_expression})")
  827. end
  828. # Remove recurring job
  829. 1 def remove_recurring_job(job_name)
  830. else: 0 then: 0 return unless defined?(Sidekiq::Cron)
  831. Sidekiq::Cron::Job.destroy("#{plugin_identifier}_#{job_name}")
  832. log("Removed recurring job: #{job_name}")
  833. end
  834. # Get all recurring jobs for this plugin
  835. 1 def recurring_jobs
  836. else: 0 then: 0 return [] unless defined?(Sidekiq::Cron)
  837. prefix = "#{plugin_identifier}_"
  838. Sidekiq::Cron::Job.all.select { |job| job.name.start_with?(prefix) }
  839. end
  840. # ========================================
  841. # UTILITY METHODS
  842. # ========================================
  843. # Get plugin name (instance method)
  844. 1 def plugin_name
  845. self.class.plugin_name
  846. end
  847. # Get plugin identifier (snake_case name)
  848. 1 def plugin_identifier
  849. plugin_name.underscore.gsub(/\s+/, '_').gsub(/[^a-z0-9_]/, '')
  850. end
  851. # Get plugin directory path
  852. 1 def plugin_path
  853. Rails.root.join('lib', 'plugins', plugin_identifier)
  854. end
  855. # Load a plugin view
  856. 1 def plugin_view(view_name)
  857. "plugins/#{plugin_identifier}/#{view_name}"
  858. end
  859. # Plugin asset URL
  860. 1 def plugin_asset_url(asset_name)
  861. "/plugins/#{plugin_identifier}/assets/#{asset_name}"
  862. end
  863. # Log plugin message
  864. 1 def log(message, level = :info)
  865. Rails.logger.send(level, "[#{name}] #{message}")
  866. end
  867. # Check if plugin meets requirements
  868. 1 def check_requirements
  869. # Override in subclass to check dependencies
  870. # Return array of error messages, or empty array if all OK
  871. []
  872. end
  873. # Plugin metadata for display
  874. 1 def metadata
  875. {
  876. name: name,
  877. version: version,
  878. description: description,
  879. author: author,
  880. url: @url,
  881. license: @license,
  882. identifier: plugin_identifier,
  883. has_settings: has_settings?,
  884. has_admin_pages: has_admin_pages?,
  885. settings_count: @settings_schema.length,
  886. admin_pages_count: @admin_pages.length
  887. }
  888. end
  889. # ========================================
  890. # CONTENT TYPE HELPERS
  891. # ========================================
  892. # Get all active content types
  893. 1 def get_content_types
  894. ContentType.active.ordered
  895. end
  896. # Get a specific content type by identifier
  897. 1 def get_content_type(ident)
  898. ContentType.find_by_ident(ident)
  899. end
  900. # Register a new content type
  901. 1 def register_content_type(ident, options = {})
  902. ContentType.find_or_create_by!(ident: ident) do |ct|
  903. ct.label = options[:label] || ident.titleize
  904. ct.singular = options[:singular] || ct.label
  905. ct.plural = options[:plural] || ct.label.pluralize
  906. ct.description = options[:description]
  907. ct.icon = options[:icon] || 'document-text'
  908. ct.public = options.fetch(:public, true)
  909. ct.hierarchical = options.fetch(:hierarchical, false)
  910. ct.has_archive = options.fetch(:has_archive, true)
  911. ct.menu_position = options[:menu_position]
  912. ct.supports = options[:supports] || ['title', 'editor', 'excerpt', 'thumbnail']
  913. ct.capabilities = options[:capabilities] || {}
  914. ct.rest_base = options[:rest_base]
  915. ct.active = options.fetch(:active, true)
  916. end
  917. log("Registered content type: #{ident}", :debug)
  918. end
  919. # Unregister a content type (marks as inactive)
  920. 1 def unregister_content_type(ident)
  921. ct = ContentType.find_by_ident(ident)
  922. then: 0 else: 0 if ct
  923. ct.update(active: false)
  924. log("Unregistered content type: #{ident}", :debug)
  925. end
  926. end
  927. # Get posts of a specific content type
  928. 1 def get_posts_by_type(ident, limit: nil)
  929. ct = get_content_type(ident)
  930. else: 0 then: 0 return Post.none unless ct
  931. posts = Post.where(content_type: ct)
  932. then: 0 else: 0 posts = posts.limit(limit) if limit
  933. posts
  934. end
  935. # ========================================
  936. # UPLOAD SYSTEM
  937. # ========================================
  938. # Upload a file securely
  939. 1 def upload_file(file, options = {})
  940. upload = Upload.new(
  941. title: options[:title] || file.original_filename,
  942. description: options[:description],
  943. alt_text: options[:alt_text]
  944. )
  945. upload.file.attach(file)
  946. then: 0 else: 0 upload.user = current_user if respond_to?(:current_user)
  947. upload.storage_provider = StorageProvider.active.first
  948. # Security validation
  949. security = UploadSecurity.current
  950. else: 0 then: 0 unless security.file_allowed?(file)
  951. raise "File not allowed: #{file.original_filename}"
  952. end
  953. # Check for suspicious files
  954. then: 0 else: 0 if security.file_suspicious?(file)
  955. then: 0 if security.quarantine_suspicious?
  956. upload.quarantined = true
  957. upload.quarantine_reason = 'Suspicious file pattern detected'
  958. else: 0 else
  959. raise "File rejected: #{file.original_filename} appears suspicious"
  960. end
  961. end
  962. upload.save!
  963. upload
  964. end
  965. # Get approved uploads
  966. 1 def get_uploads(options = {})
  967. uploads = Upload.approved
  968. then: 0 else: 0 uploads = uploads.where(user: options[:user]) if options[:user]
  969. then: 0 else: 0 uploads = uploads.where("title LIKE ?", "%#{options[:search]}%") if options[:search]
  970. then: 0 else: 0 uploads = uploads.limit(options[:limit]) if options[:limit]
  971. then: 0 else: 0 uploads = uploads.offset(options[:offset]) if options[:offset]
  972. uploads
  973. end
  974. # Get quarantined uploads
  975. 1 def get_quarantined_uploads
  976. Upload.quarantined
  977. end
  978. # Approve a quarantined upload
  979. 1 def approve_upload(upload)
  980. upload.approve!
  981. end
  982. # Reject a quarantined upload
  983. 1 def reject_upload(upload)
  984. upload.reject!
  985. end
  986. # Parse setting value based on type
  987. 1 def parse_setting_value(value, type)
  988. case type
  989. when: 0 when 'boolean'
  990. value == 'true' || value == '1' || value == true
  991. when: 0 when 'integer', 'number'
  992. value.to_i
  993. when: 0 when 'float'
  994. value.to_f
  995. when: 0 when 'array', 'json'
  996. JSON.parse(value) rescue []
  997. else: 0 else
  998. value
  999. end
  1000. end
  1001. end
  1002. end

lib/railspress/plugin_blocks.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Railspress
  3. # Plugin Blocks System - Similar to Shopify App Blocks
  4. # Allows plugins to inject custom UI blocks into admin pages (posts, pages, etc.)
  5. class PluginBlocks
  6. @blocks = {}
  7. class << self
  8. # Register a new block
  9. #
  10. # @param key [Symbol] Unique identifier for the block
  11. # @param options [Hash] Block configuration
  12. # @option options [String] :label Display name for the block
  13. # @option options [String] :description Block description
  14. # @option options [String] :icon SVG icon or icon class
  15. # @option options [Array<Symbol>] :locations Where the block can appear (:post, :page, :product, etc.)
  16. # @option options [String] :position Block position (:sidebar, :main, :footer, :header)
  17. # @option options [Integer] :order Display order (lower numbers appear first)
  18. # @option options [String] :partial Path to the partial to render
  19. # @option options [Proc] :render_proc Alternative to partial - a proc that renders the block
  20. # @option options [Hash] :settings Block settings schema
  21. # @option options [Proc] :can_render Optional proc to determine if block should render
  22. #
  23. # @example
  24. # Railspress::PluginBlocks.register(:seo_analyzer, {
  25. # label: 'SEO Analyzer',
  26. # description: 'AI-powered SEO analysis and suggestions',
  27. # icon: '<svg>...</svg>',
  28. # locations: [:post, :page],
  29. # position: :sidebar,
  30. # order: 10,
  31. # partial: 'plugins/ai_seo/analyzer_block',
  32. # can_render: ->(context) { context[:user].admin? }
  33. # })
  34. def register(key, options = {})
  35. validate_block_options!(key, options)
  36. @blocks[key] = {
  37. key: key,
  38. label: options[:label] || key.to_s.titleize,
  39. description: options[:description] || '',
  40. icon: options[:icon],
  41. locations: Array(options[:locations] || [:post, :page]),
  42. position: options[:position] || :sidebar,
  43. order: options[:order] || 100,
  44. partial: options[:partial],
  45. render_proc: options[:render_proc],
  46. settings: options[:settings] || {},
  47. can_render: options[:can_render],
  48. plugin_name: options[:plugin_name]
  49. }.freeze
  50. end
  51. # Unregister a block
  52. def unregister(key)
  53. @blocks.delete(key)
  54. end
  55. # Get all registered blocks
  56. def all
  57. @blocks.values
  58. end
  59. # Get blocks for a specific location and position
  60. #
  61. # @param location [Symbol] The location (e.g., :post, :page)
  62. # @param position [Symbol] The position (e.g., :sidebar, :main)
  63. # @param context [Hash] Context for rendering (user, record, etc.)
  64. # @return [Array<Hash>] Sorted array of block configurations
  65. def for_location(location, position: nil, context: {})
  66. blocks = @blocks.values.select do |block|
  67. next false unless block[:locations].include?(location)
  68. next false if position && block[:position] != position
  69. next false if block[:can_render] && !block[:can_render].call(context)
  70. true
  71. end
  72. blocks.sort_by { |b| b[:order] }
  73. end
  74. # Get a specific block by key
  75. def get(key)
  76. @blocks[key]
  77. end
  78. # Check if a block exists
  79. def exists?(key)
  80. @blocks.key?(key)
  81. end
  82. # Clear all blocks (useful for testing)
  83. def clear!
  84. @blocks = {}
  85. end
  86. # Render a block
  87. #
  88. # @param key [Symbol] The block key
  89. # @param context [Hash] Context for rendering
  90. # @param view_context [ActionView::Base] The view context
  91. # @return [String] Rendered HTML
  92. def render(key, context: {}, view_context:)
  93. block = get(key)
  94. return '' unless block
  95. # Ensure context is a hash
  96. unless context.is_a?(Hash)
  97. Rails.logger.warn("Plugin block context is not a hash for #{key}: #{context.class}")
  98. context = {}
  99. end
  100. return '' if block[:can_render] && !block[:can_render].call(context)
  101. if block[:render_proc]
  102. view_context.instance_exec(context, &block[:render_proc])
  103. elsif block[:partial]
  104. # Create a clean locals hash
  105. locals_hash = context.is_a?(Hash) ? context.dup : {}
  106. locals_hash[:block] = block
  107. view_context.render(
  108. partial: block[:partial],
  109. locals: locals_hash
  110. )
  111. else
  112. ''
  113. end
  114. rescue => e
  115. Rails.logger.error("Error rendering plugin block #{key}: #{e.message}")
  116. Rails.logger.error("Context class: #{context.class}")
  117. Rails.logger.error("Context value: #{context.inspect}")
  118. Rails.logger.error(e.backtrace.join("\n"))
  119. if Rails.env.development?
  120. view_context.content_tag(:div, class: 'p-4 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-sm') do
  121. "Error rendering block #{key}: #{e.message}<br/>Context: #{context.class}".html_safe
  122. end
  123. else
  124. ''
  125. end
  126. end
  127. # Render all blocks for a location and position
  128. #
  129. # @param location [Symbol] The location
  130. # @param position [Symbol] The position
  131. # @param context [Hash] Context for rendering
  132. # @param view_context [ActionView::Base] The view context
  133. # @return [String] Rendered HTML
  134. def render_all(location, position: nil, context: {}, view_context:)
  135. blocks = for_location(location, position: position, context: context)
  136. blocks.map { |block| render(block[:key], context: context, view_context: view_context) }.join.html_safe
  137. end
  138. private
  139. def validate_block_options!(key, options)
  140. raise ArgumentError, "Block key must be a symbol" unless key.is_a?(Symbol)
  141. raise ArgumentError, "Block must have either :partial or :render_proc" unless options[:partial] || options[:render_proc]
  142. if options[:locations] && !options[:locations].is_a?(Array)
  143. raise ArgumentError, "Block locations must be an array"
  144. end
  145. if options[:position] && ![:sidebar, :main, :footer, :header, :toolbar].include?(options[:position])
  146. raise ArgumentError, "Invalid block position: #{options[:position]}"
  147. end
  148. end
  149. end
  150. end
  151. end

lib/railspress/plugin_jobs.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. module Railspress
  2. module PluginJobs
  3. # Base class for plugin background jobs
  4. class Base < ApplicationJob
  5. queue_as :default
  6. # Override in subclass
  7. def perform(*args)
  8. raise NotImplementedError, "Subclass must implement #perform"
  9. end
  10. end
  11. # Helper methods for plugins to create and schedule jobs
  12. module Helpers
  13. # Create a background job for the plugin
  14. # Example:
  15. # create_job('SendEmailJob') do |job|
  16. # def perform(user_id, message)
  17. # user = User.find(user_id)
  18. # PluginMailer.send_email(user, message).deliver_now
  19. # end
  20. # end
  21. def create_job(job_name, &block)
  22. job_class_name = "#{plugin_identifier.camelize}::#{job_name}"
  23. # Define job class dynamically
  24. job_class = Class.new(Railspress::PluginJobs::Base) do
  25. class_eval(&block) if block_given?
  26. end
  27. # Set constant
  28. plugin_module = plugin_identifier.camelize.constantize rescue Object.const_set(plugin_identifier.camelize, Module.new)
  29. plugin_module.const_set(job_name, job_class)
  30. job_class
  31. end
  32. # Enqueue a job to run immediately
  33. # Example:
  34. # enqueue_job(SendEmailJob, user.id, 'Hello')
  35. def enqueue_job(job_class, *args)
  36. job_class.perform_later(*args)
  37. log("Enqueued job: #{job_class.name}")
  38. end
  39. # Schedule a job to run at specific time
  40. # Example:
  41. # schedule_job(SendEmailJob, 1.hour.from_now, user.id, 'Reminder')
  42. def schedule_job(job_class, run_at, *args)
  43. job_class.set(wait_until: run_at).perform_later(*args)
  44. log("Scheduled job: #{job_class.name} at #{run_at}")
  45. end
  46. # Schedule a job to run after delay
  47. # Example:
  48. # schedule_job_in(SendEmailJob, 30.minutes, user.id, 'Follow-up')
  49. def schedule_job_in(job_class, delay, *args)
  50. job_class.set(wait: delay).perform_later(*args)
  51. log("Scheduled job: #{job_class.name} in #{delay}")
  52. end
  53. # Schedule recurring job (using Sidekiq-cron)
  54. # Example:
  55. # schedule_recurring_job('daily_cleanup', '0 2 * * *', CleanupJob)
  56. def schedule_recurring_job(job_name, cron_expression, job_class, *args)
  57. require 'sidekiq-cron'
  58. Sidekiq::Cron::Job.create(
  59. name: "#{plugin_identifier}_#{job_name}",
  60. cron: cron_expression,
  61. class: job_class.name,
  62. args: args.to_json
  63. )
  64. log("Scheduled recurring job: #{job_name} (#{cron_expression})")
  65. rescue LoadError
  66. log("Sidekiq-cron not available. Install it to use recurring jobs.", :warn)
  67. end
  68. # Remove recurring job
  69. # Example:
  70. # remove_recurring_job('daily_cleanup')
  71. def remove_recurring_job(job_name)
  72. require 'sidekiq-cron'
  73. Sidekiq::Cron::Job.destroy("#{plugin_identifier}_#{job_name}")
  74. log("Removed recurring job: #{job_name}")
  75. rescue LoadError
  76. # Silently skip if sidekiq-cron not available
  77. end
  78. # Get all recurring jobs for this plugin
  79. def recurring_jobs
  80. require 'sidekiq-cron'
  81. prefix = "#{plugin_identifier}_"
  82. Sidekiq::Cron::Job.all.select { |job| job.name.start_with?(prefix) }
  83. rescue LoadError
  84. []
  85. end
  86. # Check if Sidekiq is available
  87. def sidekiq_available?
  88. defined?(Sidekiq)
  89. end
  90. # Get job queue name
  91. def job_queue
  92. :"#{plugin_identifier}_jobs"
  93. end
  94. # Set custom queue for plugin jobs
  95. # Example:
  96. # use_queue(:critical) # or :default, :low_priority
  97. def use_queue(queue_name)
  98. @job_queue = queue_name
  99. end
  100. # Enqueue multiple jobs at once
  101. # Example:
  102. # enqueue_batch([
  103. # [SendEmailJob, user1.id],
  104. # [SendEmailJob, user2.id],
  105. # [ProcessDataJob, data.id]
  106. # ])
  107. def enqueue_batch(jobs)
  108. jobs.each do |job_class, *args|
  109. enqueue_job(job_class, *args)
  110. end
  111. end
  112. # Check job status (if using Sidekiq Pro)
  113. def job_status(job_id)
  114. return unless sidekiq_available?
  115. # This requires Sidekiq Pro
  116. # Sidekiq::Status.get(job_id)
  117. end
  118. # Clear all jobs for this plugin
  119. def clear_plugin_jobs
  120. return unless sidekiq_available?
  121. # Remove from Redis queue
  122. Sidekiq::Queue.all.each do |queue|
  123. queue.each do |job|
  124. job.delete if job.klass.start_with?(plugin_identifier.camelize)
  125. end
  126. end
  127. log("Cleared all plugin jobs from queues")
  128. end
  129. end
  130. end
  131. end

lib/railspress/plugin_system.rb

26.69% lines covered

6.0% branches covered

281 relevant lines. 75 lines covered and 206 lines missed.
100 total branches, 6 branches covered and 94 branches missed.
    
  1. 1 module Railspress
  2. 1 module PluginSystem
  3. 1 class << self
  4. 1 attr_accessor :plugins, :hooks, :filters, :admin_pages, :plugin_routes
  5. 1 def initialize_system
  6. 1 @plugins = {}
  7. 1 @hooks = Hash.new { |hash, key| hash[key] = [] }
  8. 1 @filters = Hash.new { |hash, key| hash[key] = [] }
  9. 1 @admin_pages = Hash.new { |hash, key| hash[key] = [] }
  10. 1 @plugin_routes = {}
  11. 1 @plugin_admin_routes = {}
  12. 1 @plugin_frontend_routes = {}
  13. # Enhanced plugin features
  14. 1 @webhooks = Hash.new { |hash, key| hash[key] = [] }
  15. 1 @event_listeners = Hash.new { |hash, key| hash[key] = [] }
  16. 1 @middleware_stack = Hash.new { |hash, key| hash[key] = [] }
  17. 1 @assets = Hash.new { |hash, key| hash[key] = [] }
  18. 1 @api_endpoints = Hash.new { |hash, key| hash[key] = [] }
  19. 1 @theme_templates = Hash.new { |hash, key| hash[key] = [] }
  20. 1 @theme_assets = Hash.new { |hash, key| hash[key] = [] }
  21. 1 @theme_settings = Hash.new { |hash, key| hash[key] = [] }
  22. 1 @validators = Hash.new { |hash, key| hash[key] = [] }
  23. 1 @commands = Hash.new { |hash, key| hash[key] = [] }
  24. 1 @scheduled_tasks = Hash.new { |hash, key| hash[key] = [] }
  25. 1 @initialized = true
  26. end
  27. # Register a plugin
  28. 1 def register_plugin(name, plugin_class)
  29. @plugins[name] = plugin_class
  30. Rails.logger.info "Plugin registered: #{name}"
  31. end
  32. # Reload plugins (for development)
  33. 1 def reload_plugins
  34. else: 0 then: 0 return unless Rails.env.development?
  35. # Clear existing state completely
  36. @plugins = {}
  37. @hooks = Hash.new { |hash, key| hash[key] = [] }
  38. # Reload all active plugins
  39. load_plugins
  40. Rails.logger.info "Plugins reloaded: #{loaded_plugins.join(', ')}"
  41. puts "✅ Plugins reloaded: #{loaded_plugins.join(', ')}"
  42. end
  43. # Load all active plugins from database
  44. 1 def load_plugins
  45. 1 else: 1 then: 0 initialize_system unless @plugins
  46. # Skip loading plugins if database tables don't exist yet (e.g., during migrations)
  47. 1 else: 1 then: 0 return unless ActiveRecord::Base.connection.table_exists?('plugins')
  48. 1 Plugin.active.find_each do |plugin_record|
  49. # Use dynamic plugin discovery to find the correct plugin file
  50. plugin_path = find_plugin_file(plugin_record.name)
  51. if plugin_path && File.exist?(plugin_path)
  52. begin
  53. then: 0 # Use load instead of require for development reloading
  54. then: 0 else: 0 load_method = Rails.env.development? ? :load : :require
  55. send(load_method, plugin_path)
  56. # Find and instantiate the plugin class
  57. plugin_instance = instantiate_plugin(plugin_record.name)
  58. if plugin_instance
  59. then: 0 # Call the activate method to register hooks
  60. plugin_instance.activate
  61. @plugins[plugin_record.name] = plugin_instance
  62. Rails.logger.info "Loaded, instantiated, and activated plugin: #{plugin_record.name}"
  63. else: 0 else
  64. Rails.logger.warn "Failed to instantiate plugin: #{plugin_record.name}"
  65. end
  66. rescue => e
  67. Rails.logger.error "Failed to load plugin #{plugin_record.name}: #{e.message}"
  68. end
  69. else: 0 else
  70. Rails.logger.warn "Plugin file not found for: #{plugin_record.name}"
  71. end
  72. end
  73. end
  74. # Find plugin file using dynamic discovery
  75. 1 def find_plugin_file(plugin_name)
  76. plugins_dir = Rails.root.join('lib', 'plugins')
  77. else: 0 then: 0 return nil unless Dir.exist?(plugins_dir)
  78. Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
  79. else: 0 then: 0 next unless File.directory?(plugin_dir)
  80. candidate_name = File.basename(plugin_dir)
  81. plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
  82. else: 0 then: 0 next unless File.exist?(plugin_file)
  83. begin
  84. # Load the plugin file to check if it matches our plugin
  85. load plugin_file
  86. plugin_class_name = candidate_name.classify
  87. plugin_class = plugin_class_name.constantize rescue nil
  88. else: 0 if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
  89. then: 0 # Create a temporary instance to check the name
  90. temp_instance = plugin_class.new
  91. then: 0 else: 0 if temp_instance.name == plugin_name
  92. return plugin_file
  93. end
  94. end
  95. rescue => e
  96. # Continue to next plugin if this one fails
  97. next
  98. end
  99. end
  100. nil
  101. end
  102. # Instantiate a plugin by name
  103. 1 def instantiate_plugin(plugin_name)
  104. plugins_dir = Rails.root.join('lib', 'plugins')
  105. else: 0 then: 0 return nil unless Dir.exist?(plugins_dir)
  106. Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
  107. else: 0 then: 0 next unless File.directory?(plugin_dir)
  108. candidate_name = File.basename(plugin_dir)
  109. plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
  110. else: 0 then: 0 next unless File.exist?(plugin_file)
  111. begin
  112. plugin_class_name = candidate_name.classify
  113. plugin_class = plugin_class_name.constantize rescue nil
  114. else: 0 if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
  115. then: 0 # Create a temporary instance to check the name
  116. temp_instance = plugin_class.new
  117. then: 0 else: 0 if temp_instance.name == plugin_name
  118. return temp_instance
  119. end
  120. end
  121. rescue => e
  122. # Continue to next plugin if this one fails
  123. next
  124. end
  125. end
  126. nil
  127. end
  128. # Add an action hook
  129. 1 def add_action(hook_name, callback, priority = 10, plugin_name = nil)
  130. @hooks ||= Hash.new { |hash, key| hash[key] = [] }
  131. @hooks[hook_name] << {
  132. callback: callback,
  133. priority: priority,
  134. plugin_name: plugin_name
  135. }
  136. @hooks[hook_name].sort_by! { |h| h[:priority] }
  137. end
  138. # Execute action hooks
  139. 1 def do_action(hook_name, *args)
  140. else: 0 then: 0 return unless @hooks[hook_name]
  141. results = []
  142. @hooks[hook_name].each do |hook|
  143. # Skip hooks from deactivated plugins
  144. else: 0 then: 0 next unless plugin_active?(hook[:plugin_name])
  145. begin
  146. then: 0 if hook[:callback].respond_to?(:call)
  147. result = hook[:callback].call(*args)
  148. else: 0 then: 0 else: 0 results << result if result
  149. else: 0 elsif hook[:callback].is_a?(Symbol) || hook[:callback].is_a?(String)
  150. then: 0 # If it's a method name, try to call it
  151. method_name = hook[:callback].to_sym
  152. then: 0 else: 0 if self.respond_to?(method_name)
  153. result = self.send(method_name, *args)
  154. then: 0 else: 0 results << result if result
  155. end
  156. end
  157. rescue => e
  158. Rails.logger.error "Error executing hook #{hook_name} from plugin #{hook[:plugin_name]}: #{e.message}"
  159. end
  160. end
  161. # Return the results joined together (for HTML output)
  162. results.join.html_safe
  163. end
  164. # Add a filter hook
  165. 1 def add_filter(filter_name, callback, priority = 10)
  166. @filters ||= Hash.new { |hash, key| hash[key] = [] }
  167. @filters[filter_name] << { callback: callback, priority: priority }
  168. @filters[filter_name].sort_by! { |f| f[:priority] }
  169. end
  170. # Apply filters
  171. 1 def apply_filters(filter_name, value, *args)
  172. else: 0 then: 0 return value unless @filters[filter_name]
  173. @filters[filter_name].reduce(value) do |filtered_value, filter|
  174. begin
  175. then: 0 if filter[:callback].respond_to?(:call)
  176. filter[:callback].call(filtered_value, *args)
  177. else: 0 else
  178. filtered_value
  179. end
  180. rescue => e
  181. Rails.logger.error "Error applying filter #{filter_name}: #{e.message}"
  182. filtered_value
  183. end
  184. end
  185. end
  186. # Check if plugin is loaded
  187. 1 def plugin_loaded?(name)
  188. @plugins.key?(name)
  189. end
  190. # Get plugin instance
  191. 1 def get_plugin(name)
  192. @plugins[name]
  193. end
  194. # Get all loaded plugins
  195. 1 def loaded_plugins
  196. 1 @plugins.keys
  197. end
  198. # Check if a plugin is active
  199. 1 def plugin_active?(plugin_name)
  200. else: 0 then: 0 return true unless plugin_name # Allow hooks without plugin names (backward compatibility)
  201. # Check if plugin exists and is active in the database
  202. else: 0 then: 0 return false unless ActiveRecord::Base.connection.table_exists?('plugins')
  203. Plugin.exists?(name: plugin_name, active: true)
  204. end
  205. # Register admin page for a plugin
  206. 1 def register_admin_page(plugin_identifier, page_config)
  207. @admin_pages ||= Hash.new { |hash, key| hash[key] = [] }
  208. @admin_pages[plugin_identifier] << page_config
  209. Rails.logger.info "Registered admin page for #{plugin_identifier}: #{page_config[:title]}"
  210. end
  211. # Get all admin pages for a plugin
  212. 1 def get_plugin_admin_pages(plugin_identifier)
  213. then: 0 else: 0 @admin_pages&.dig(plugin_identifier) || []
  214. end
  215. # Register plugin routes
  216. 1 def register_plugin_routes(plugin_identifier, routes_block)
  217. @plugin_routes ||= {}
  218. @plugin_routes[plugin_identifier] = routes_block
  219. Rails.logger.info "Registered routes for plugin: #{plugin_identifier}"
  220. end
  221. # Register admin routes for a plugin
  222. 1 def register_plugin_admin_routes(plugin_identifier, routes_block)
  223. @plugin_admin_routes ||= {}
  224. @plugin_admin_routes[plugin_identifier] = routes_block
  225. Rails.logger.info "Registered admin routes for plugin: #{plugin_identifier}"
  226. end
  227. # Register frontend routes for a plugin
  228. 1 def register_plugin_frontend_routes(plugin_identifier, routes_block)
  229. @plugin_frontend_routes ||= {}
  230. @plugin_frontend_routes[plugin_identifier] = routes_block
  231. Rails.logger.info "Registered frontend routes for plugin: #{plugin_identifier}"
  232. end
  233. # Get all plugin admin pages
  234. 1 def all_plugin_admin_pages
  235. then: 0 else: 0 then: 0 else: 0 @admin_pages&.values&.flatten || []
  236. end
  237. # Get all plugin routes
  238. 1 def all_plugin_routes
  239. @plugin_routes || {}
  240. end
  241. # Load all plugin routes into the Rails router
  242. # Called from config/initializers/plugin_system.rb after plugins are loaded
  243. 1 def load_plugin_routes!
  244. 1 total_routes = 0
  245. 1 then: 1 else: 0 total_routes += @plugin_admin_routes&.size || 0
  246. 1 then: 1 else: 0 total_routes += @plugin_frontend_routes&.size || 0
  247. 1 then: 1 else: 0 total_routes += @plugin_routes&.size || 0
  248. 1 then: 1 else: 0 return if total_routes == 0
  249. Rails.logger.info "Loading routes for #{total_routes} plugin route blocks..."
  250. Rails.application.routes.append do
  251. # Load admin routes (scoped under /admin for security)
  252. then: 0 else: 0 then: 0 else: 0 if @plugin_admin_routes&.any?
  253. Rails.logger.info "Loading admin routes..."
  254. namespace :admin do
  255. @plugin_admin_routes.each do |plugin_identifier, routes_block|
  256. begin
  257. Rails.logger.info " → Loading admin routes for: #{plugin_identifier}"
  258. # Wrap each plugin's routes in a namespace for isolation
  259. namespace plugin_identifier.underscore.to_sym do
  260. then: 0 else: 0 instance_eval(&routes_block) if routes_block
  261. end
  262. rescue => e
  263. Rails.logger.error " ✗ Failed to load admin routes for #{plugin_identifier}: #{e.message}"
  264. Rails.logger.error e.backtrace.first(5).join("\n")
  265. end
  266. end
  267. end
  268. end
  269. # Load frontend routes (scoped under /plugins for security)
  270. then: 0 else: 0 then: 0 else: 0 if @plugin_frontend_routes&.any?
  271. Rails.logger.info "Loading frontend routes..."
  272. scope '/plugins' do
  273. @plugin_frontend_routes.each do |plugin_identifier, routes_block|
  274. begin
  275. Rails.logger.info " → Loading frontend routes for: #{plugin_identifier}"
  276. # Wrap each plugin's routes in a scope for isolation
  277. scope plugin_identifier.underscore do
  278. then: 0 else: 0 instance_eval(&routes_block) if routes_block
  279. end
  280. rescue => e
  281. Rails.logger.error " ✗ Failed to load frontend routes for #{plugin_identifier}: #{e.message}"
  282. Rails.logger.error e.backtrace.first(5).join("\n")
  283. end
  284. end
  285. end
  286. end
  287. # Load legacy routes (backward compatibility - treated as admin routes)
  288. then: 0 else: 0 then: 0 else: 0 if @plugin_routes&.any?
  289. Rails.logger.info "Loading legacy routes (as admin routes)..."
  290. namespace :admin do
  291. @plugin_routes.each do |plugin_identifier, routes_block|
  292. begin
  293. Rails.logger.info " → Loading legacy routes for: #{plugin_identifier}"
  294. namespace plugin_identifier.underscore.to_sym do
  295. then: 0 else: 0 instance_eval(&routes_block) if routes_block
  296. end
  297. rescue => e
  298. Rails.logger.error " ✗ Failed to load legacy routes for #{plugin_identifier}: #{e.message}"
  299. Rails.logger.error e.backtrace.first(5).join("\n")
  300. end
  301. end
  302. end
  303. end
  304. end
  305. Rails.logger.info "✓ Plugin routes loaded successfully"
  306. rescue => e
  307. Rails.logger.error "Failed to load plugin routes: #{e.message}"
  308. Rails.logger.error e.backtrace.first(10).join("\n")
  309. end
  310. # ========================================
  311. # WEBHOOK SYSTEM
  312. # ========================================
  313. 1 def register_webhook(plugin_identifier, webhook)
  314. @webhooks[plugin_identifier] << webhook
  315. Rails.logger.info "Registered webhook for #{plugin_identifier}: #{webhook[:event]}"
  316. end
  317. 1 def trigger_webhook(plugin_identifier, event_name, data)
  318. webhooks = @webhooks[plugin_identifier].select { |w| w[:event] == event_name && w[:active] }
  319. webhooks.each do |webhook|
  320. WebhookJob.perform_later(webhook, data)
  321. end
  322. Rails.logger.info "Triggered #{webhooks.size} webhooks for #{plugin_identifier}:#{event_name}"
  323. end
  324. # ========================================
  325. # EVENT SYSTEM
  326. # ========================================
  327. 1 def register_event_listener(plugin_identifier, event)
  328. @event_listeners[plugin_identifier] << event
  329. Rails.logger.info "Registered event listener for #{plugin_identifier}: #{event[:name]}"
  330. end
  331. 1 def emit_event(event_name, data = {})
  332. listeners = []
  333. @event_listeners.each do |plugin_id, events|
  334. events.select { |e| e[:name] == event_name }.each do |event|
  335. listeners << { plugin: plugin_id, callback: event[:callback] }
  336. end
  337. end
  338. listeners.sort_by { |l| l[:priority] || 10 }.each do |listener|
  339. begin
  340. listener[:callback].call(data)
  341. rescue => e
  342. Rails.logger.error "Event listener error in #{listener[:plugin]}: #{e.message}"
  343. end
  344. end
  345. Rails.logger.info "Emitted event #{event_name} to #{listeners.size} listeners"
  346. end
  347. # ========================================
  348. # MIDDLEWARE SYSTEM
  349. # ========================================
  350. 1 def register_middleware(plugin_identifier, middleware)
  351. @middleware_stack[plugin_identifier] << middleware
  352. Rails.logger.info "Registered middleware for #{plugin_identifier}: #{middleware[:class]}"
  353. end
  354. 1 def load_plugin_middleware
  355. @middleware_stack.each do |plugin_id, middleware_list|
  356. middleware_list.each do |middleware|
  357. begin
  358. Rails.application.middleware.use middleware[:class], *middleware[:args], &middleware[:block]
  359. Rails.logger.info "Loaded middleware for #{plugin_id}: #{middleware[:class]}"
  360. rescue => e
  361. Rails.logger.error "Failed to load middleware for #{plugin_id}: #{e.message}"
  362. end
  363. end
  364. end
  365. end
  366. # ========================================
  367. # ASSET MANAGEMENT
  368. # ========================================
  369. 1 def register_asset(plugin_identifier, asset)
  370. @assets[plugin_identifier] << asset
  371. Rails.logger.info "Registered asset for #{plugin_identifier}: #{asset[:path]}"
  372. end
  373. 1 def get_plugin_assets(plugin_identifier, type = nil, context = :all)
  374. assets = @assets[plugin_identifier]
  375. then: 0 else: 0 assets = assets.select { |a| a[:type] == type } if type
  376. then: 0 else: 0 assets = assets.select { |a| a[:admin_only] == true } if context == :admin
  377. then: 0 else: 0 assets = assets.select { |a| a[:frontend_only] == true } if context == :frontend
  378. assets.sort_by { |a| a[:priority] || 10 }
  379. end
  380. # ========================================
  381. # API ENDPOINTS
  382. # ========================================
  383. 1 def register_api_endpoint(plugin_identifier, endpoint)
  384. @api_endpoints[plugin_identifier] << endpoint
  385. Rails.logger.info "Registered API endpoint for #{plugin_identifier}: #{endpoint[:method]} #{endpoint[:path]}"
  386. end
  387. 1 def load_plugin_api_routes
  388. else: 0 then: 0 return unless @api_endpoints.any?
  389. Rails.application.routes.append do
  390. namespace :api do
  391. @api_endpoints.each do |plugin_id, endpoints|
  392. namespace plugin_id.underscore.to_sym do
  393. endpoints.each do |endpoint|
  394. begin
  395. send(endpoint[:method].downcase, endpoint[:path],
  396. to: "#{endpoint[:controller]}##{endpoint[:action]}",
  397. defaults: { plugin: plugin_id })
  398. rescue => e
  399. Rails.logger.error "Failed to register API route for #{plugin_id}: #{e.message}"
  400. end
  401. end
  402. end
  403. end
  404. end
  405. end
  406. end
  407. # ========================================
  408. # THEME SYSTEM
  409. # ========================================
  410. 1 def register_theme_template(plugin_identifier, template)
  411. @theme_templates[plugin_identifier] << template
  412. Rails.logger.info "Registered theme template for #{plugin_identifier}: #{template[:name]}"
  413. end
  414. 1 def register_theme_asset(plugin_identifier, asset)
  415. @theme_assets[plugin_identifier] << asset
  416. Rails.logger.info "Registered theme asset for #{plugin_identifier}: #{asset[:path]}"
  417. end
  418. 1 def register_theme_setting(plugin_identifier, setting)
  419. @theme_settings[plugin_identifier] << setting
  420. Rails.logger.info "Registered theme setting for #{plugin_identifier}: #{setting[:key]}"
  421. end
  422. # ========================================
  423. # VALIDATORS
  424. # ========================================
  425. 1 def register_validator(plugin_identifier, validator)
  426. @validators[plugin_identifier] << validator
  427. Rails.logger.info "Registered validator for #{plugin_identifier}: #{validator[:name]}"
  428. end
  429. # ========================================
  430. # COMMANDS
  431. # ========================================
  432. 1 def register_command(plugin_identifier, command)
  433. @commands[plugin_identifier] << command
  434. Rails.logger.info "Registered command for #{plugin_identifier}: #{command[:name]}"
  435. end
  436. 1 def load_plugin_commands
  437. @commands.each do |plugin_id, commands|
  438. commands.each do |command|
  439. begin
  440. Rake::Task.define_task("#{plugin_id}:#{command[:name]}") do |t|
  441. puts "Running #{plugin_id}:#{command[:name]} - #{command[:description]}"
  442. command[:block].call
  443. end
  444. rescue => e
  445. Rails.logger.error "Failed to register command for #{plugin_id}: #{e.message}"
  446. end
  447. end
  448. end
  449. end
  450. # ========================================
  451. # NOTIFICATIONS
  452. # ========================================
  453. 1 def notify_admin(plugin_identifier, message, type, options = {})
  454. # Create admin notification
  455. AdminNotification.create!(
  456. plugin: plugin_identifier,
  457. message: message,
  458. notification_type: type,
  459. metadata: options
  460. )
  461. Rails.logger.info "Admin notification sent from #{plugin_identifier}: #{message}"
  462. end
  463. 1 def notify_user(plugin_identifier, user_id, message, type, options = {})
  464. # Create user notification
  465. UserNotification.create!(
  466. plugin: plugin_identifier,
  467. user_id: user_id,
  468. message: message,
  469. notification_type: type,
  470. metadata: options
  471. )
  472. Rails.logger.info "User notification sent from #{plugin_identifier} to user #{user_id}: #{message}"
  473. end
  474. # ========================================
  475. # SCHEDULER
  476. # ========================================
  477. 1 def schedule_task(plugin_identifier, task)
  478. @scheduled_tasks[plugin_identifier] << task
  479. Rails.logger.info "Scheduled task for #{plugin_identifier}: #{task[:name]}"
  480. # Schedule with cron job system
  481. then: 0 else: 0 if defined?(Sidekiq)
  482. Sidekiq::Cron::Job.create(
  483. name: "#{plugin_identifier}:#{task[:name]}",
  484. cron: task[:cron],
  485. class: 'PluginTaskWorker',
  486. args: [plugin_identifier, task[:name]]
  487. )
  488. end
  489. end
  490. # ========================================
  491. # DATABASE HELPERS
  492. # ========================================
  493. 1 def create_plugin_migration(plugin_identifier, table_name, &block)
  494. timestamp = Time.current.strftime('%Y%m%d%H%M%S')
  495. filename = "#{timestamp}_create_#{plugin_identifier}_#{table_name}.rb"
  496. migration_path = Rails.root.join('db', 'migrate', filename)
  497. migration_content = <<~RUBY
  498. class Create#{plugin_identifier.classify}#{table_name.classify} < ActiveRecord::Migration[7.1]
  499. def change
  500. create_table :#{plugin_identifier}_#{table_name} do |t|
  501. then: 0 else: 0 #{block ? block.call : '# Add columns here'}
  502. t.timestamps
  503. end
  504. end
  505. end
  506. RUBY
  507. File.write(migration_path, migration_content)
  508. Rails.logger.info "Created migration: #{filename}"
  509. migration_path
  510. end
  511. 1 def add_plugin_column(plugin_identifier, table_name, column_name, type, options = {})
  512. timestamp = Time.current.strftime('%Y%m%d%H%M%S')
  513. filename = "#{timestamp}_add_#{column_name}_to_#{plugin_identifier}_#{table_name}.rb"
  514. migration_path = Rails.root.join('db', 'migrate', filename)
  515. migration_content = <<~RUBY
  516. class Add#{column_name.classify}To#{plugin_identifier.classify}#{table_name.classify} < ActiveRecord::Migration[7.1]
  517. def change
  518. then: 0 else: 0 add_column :#{plugin_identifier}_#{table_name}, :#{column_name}, :#{type}#{options.empty? ? '' : ', ' + options.inspect}
  519. end
  520. end
  521. RUBY
  522. File.write(migration_path, migration_content)
  523. Rails.logger.info "Created column migration: #{filename}"
  524. migration_path
  525. end
  526. end
  527. end
  528. end

lib/railspress/settings_schema.rb

0.0% lines covered

100.0% branches covered

192 relevant lines. 0 lines covered and 192 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Railspress
  3. class SettingsSchema
  4. attr_reader :sections, :plugin_name
  5. def initialize(plugin_name)
  6. @plugin_name = plugin_name
  7. @sections = []
  8. end
  9. # Define a settings section
  10. def section(title, **options, &block)
  11. section = Section.new(title, options)
  12. section.instance_eval(&block) if block_given?
  13. @sections << section
  14. section
  15. end
  16. # Get all fields from all sections
  17. def all_fields
  18. @sections.flat_map(&:fields)
  19. end
  20. # Get field by key
  21. def find_field(key)
  22. all_fields.find { |f| f.key == key.to_s }
  23. end
  24. # Validate settings hash
  25. def validate(settings)
  26. errors = {}
  27. all_fields.each do |field|
  28. value = settings[field.key]
  29. field_errors = field.validate(value)
  30. errors[field.key] = field_errors if field_errors.any?
  31. end
  32. errors
  33. end
  34. # Section class
  35. class Section
  36. attr_reader :title, :description, :fields
  37. def initialize(title, options = {})
  38. @title = title
  39. @description = options[:description]
  40. @fields = []
  41. end
  42. # Field types
  43. def text(key, label, **options)
  44. add_field(TextField.new(key, label, options))
  45. end
  46. def textarea(key, label, **options)
  47. add_field(TextareaField.new(key, label, options))
  48. end
  49. def number(key, label, **options)
  50. add_field(NumberField.new(key, label, options))
  51. end
  52. def checkbox(key, label, **options)
  53. add_field(CheckboxField.new(key, label, options))
  54. end
  55. def select(key, label, choices, **options)
  56. add_field(SelectField.new(key, label, choices, options))
  57. end
  58. def radio(key, label, choices, **options)
  59. add_field(RadioField.new(key, label, choices, options))
  60. end
  61. def email(key, label, **options)
  62. add_field(EmailField.new(key, label, options))
  63. end
  64. def url(key, label, **options)
  65. add_field(UrlField.new(key, label, options))
  66. end
  67. def color(key, label, **options)
  68. add_field(ColorField.new(key, label, options))
  69. end
  70. def file(key, label, **options)
  71. add_field(FileField.new(key, label, options))
  72. end
  73. def wysiwyg(key, label, **options)
  74. add_field(WysiwygField.new(key, label, options))
  75. end
  76. def code(key, label, **options)
  77. add_field(CodeField.new(key, label, options))
  78. end
  79. def custom(key, label, **options, &block)
  80. add_field(CustomField.new(key, label, options, &block))
  81. end
  82. private
  83. def add_field(field)
  84. @fields << field
  85. field
  86. end
  87. end
  88. # Base field class
  89. class BaseField
  90. attr_reader :key, :label, :options
  91. def initialize(key, label, options = {})
  92. @key = key.to_s
  93. @label = label
  94. @options = options
  95. end
  96. def required?
  97. @options[:required] == true
  98. end
  99. def default
  100. @options[:default]
  101. end
  102. def description
  103. @options[:description]
  104. end
  105. def placeholder
  106. @options[:placeholder]
  107. end
  108. def validate(value)
  109. errors = []
  110. if required? && value.blank?
  111. errors << "#{label} is required"
  112. end
  113. if @options[:min] && value.to_i < @options[:min]
  114. errors << "#{label} must be at least #{@options[:min]}"
  115. end
  116. if @options[:max] && value.to_i > @options[:max]
  117. errors << "#{label} must be at most #{@options[:max]}"
  118. end
  119. if @options[:pattern] && value.present? && !value.match?(@options[:pattern])
  120. errors << "#{label} format is invalid"
  121. end
  122. errors
  123. end
  124. def input_type
  125. 'text'
  126. end
  127. def render_options
  128. {
  129. type: input_type,
  130. required: required?,
  131. placeholder: placeholder,
  132. description: description
  133. }.compact
  134. end
  135. end
  136. # Specific field types
  137. class TextField < BaseField
  138. def input_type; 'text'; end
  139. end
  140. class TextareaField < BaseField
  141. def input_type; 'textarea'; end
  142. def rows; @options[:rows] || 4; end
  143. end
  144. class NumberField < BaseField
  145. def input_type; 'number'; end
  146. def min; @options[:min]; end
  147. def max; @options[:max]; end
  148. def step; @options[:step] || 1; end
  149. end
  150. class CheckboxField < BaseField
  151. def input_type; 'checkbox'; end
  152. end
  153. class SelectField < BaseField
  154. attr_reader :choices
  155. def initialize(key, label, choices, options = {})
  156. super(key, label, options)
  157. @choices = choices
  158. end
  159. def input_type; 'select'; end
  160. end
  161. class RadioField < BaseField
  162. attr_reader :choices
  163. def initialize(key, label, choices, options = {})
  164. super(key, label, options)
  165. @choices = choices
  166. end
  167. def input_type; 'radio'; end
  168. end
  169. class EmailField < BaseField
  170. def input_type; 'email'; end
  171. end
  172. class UrlField < BaseField
  173. def input_type; 'url'; end
  174. end
  175. class ColorField < BaseField
  176. def input_type; 'color'; end
  177. end
  178. class FileField < BaseField
  179. def input_type; 'file'; end
  180. def accept; @options[:accept]; end
  181. end
  182. class WysiwygField < BaseField
  183. def input_type; 'wysiwyg'; end
  184. def editor; @options[:editor] || 'trix'; end
  185. end
  186. class CodeField < BaseField
  187. def input_type; 'code'; end
  188. def language; @options[:language] || 'plaintext'; end
  189. end
  190. class CustomField < BaseField
  191. def initialize(key, label, options = {}, &block)
  192. super(key, label, options)
  193. @render_block = block
  194. end
  195. def input_type; 'custom'; end
  196. def render(form_builder, value)
  197. @render_block.call(form_builder, value) if @render_block
  198. end
  199. end
  200. end
  201. end

lib/railspress/shortcode_processor.rb

29.27% lines covered

0.0% branches covered

123 relevant lines. 36 lines covered and 87 lines missed.
34 total branches, 0 branches covered and 34 branches missed.
    
  1. 1 module Railspress
  2. 1 class ShortcodeProcessor
  3. 1 class << self
  4. 1 attr_accessor :shortcodes
  5. 1 def initialize_processor
  6. 1 @shortcodes = {}
  7. 1 register_default_shortcodes
  8. end
  9. # Register a shortcode
  10. 1 def register(name, &block)
  11. 8 @shortcodes ||= {}
  12. 8 @shortcodes[name.to_s] = block
  13. 8 Rails.logger.info "Shortcode registered: #{name}"
  14. end
  15. # Process content containing shortcodes
  16. 1 def process(content, context = {})
  17. then: 0 else: 0 return content if content.blank?
  18. # Pattern matches [shortcode attr="value"] or [shortcode]content[/shortcode]
  19. content.gsub(/\[(\w+)([^\]]*)\](?:([^\[]*)\[\/\1\])?/) do
  20. shortcode_name = $1
  21. attributes_str = $2
  22. inner_content = $3
  23. then: 0 if @shortcodes.key?(shortcode_name)
  24. attrs = parse_attributes(attributes_str)
  25. execute_shortcode(shortcode_name, attrs, inner_content, context)
  26. else
  27. else: 0 # Return original if shortcode not found
  28. $&
  29. end
  30. end
  31. end
  32. # Execute a specific shortcode
  33. 1 def execute_shortcode(name, attributes, content, context)
  34. shortcode = @shortcodes[name]
  35. else: 0 then: 0 return '' unless shortcode
  36. begin
  37. then: 0 if shortcode.arity == 3
  38. else: 0 shortcode.call(attributes, content, context)
  39. then: 0 elsif shortcode.arity == 2
  40. shortcode.call(attributes, content)
  41. else: 0 else
  42. shortcode.call(attributes)
  43. end
  44. rescue => e
  45. Rails.logger.error "Error executing shortcode #{name}: #{e.message}"
  46. "[Error in #{name} shortcode]"
  47. end
  48. end
  49. # Parse shortcode attributes
  50. 1 def parse_attributes(attr_string)
  51. then: 0 else: 0 return {} if attr_string.blank?
  52. attrs = {}
  53. attr_string.scan(/(\w+)=["']([^"']+)["']|(\w+)=(\S+)/) do |match|
  54. key = match[0] || match[2]
  55. value = match[1] || match[3]
  56. attrs[key.to_sym] = value
  57. end
  58. attrs
  59. end
  60. # Check if shortcode exists
  61. 1 def exists?(name)
  62. @shortcodes.key?(name.to_s)
  63. end
  64. # Get all registered shortcodes
  65. 1 def all
  66. 1 @shortcodes.keys
  67. end
  68. # Remove a shortcode
  69. 1 def unregister(name)
  70. @shortcodes.delete(name.to_s)
  71. end
  72. 1 private
  73. # Register default shortcodes
  74. 1 def register_default_shortcodes
  75. # Gallery shortcode
  76. 1 register('gallery') do |attrs, content|
  77. then: 0 else: 0 then: 0 else: 0 ids = attrs[:ids]&.split(',')&.map(&:to_i) || []
  78. then: 0 else: 0 columns = attrs[:columns]&.to_i || 3
  79. size = attrs[:size] || 'medium'
  80. then: 0 if ids.any?
  81. media = Medium.where(id: ids)
  82. render_gallery(media, columns, size)
  83. else: 0 else
  84. ''
  85. end
  86. end
  87. # Button shortcode
  88. 1 register('button') do |attrs, content|
  89. url = attrs[:url] || '#'
  90. style = attrs[:style] || 'primary'
  91. size = attrs[:size] || 'medium'
  92. target = attrs[:target] || '_self'
  93. render_button(content || 'Click Here', url, style, size, target)
  94. end
  95. # YouTube shortcode
  96. 1 register('youtube') do |attrs|
  97. video_id = attrs[:id]
  98. width = attrs[:width] || '560'
  99. height = attrs[:height] || '315'
  100. render_youtube(video_id, width, height)
  101. end
  102. # Recent Posts shortcode
  103. 1 register('recent_posts') do |attrs|
  104. then: 0 else: 0 count = attrs[:count]&.to_i || 5
  105. category = attrs[:category]
  106. posts = Post.published.recent
  107. then: 0 else: 0 posts = posts.by_category(category) if category
  108. posts = posts.limit(count)
  109. render_recent_posts(posts)
  110. end
  111. # Contact Form shortcode
  112. 1 register('contact_form') do |attrs|
  113. form_id = attrs[:id] || 'contact'
  114. email = attrs[:email] || SiteSetting.get('admin_email', 'admin@example.com')
  115. render_contact_form(form_id, email)
  116. end
  117. # Columns shortcode
  118. 1 register('columns') do |attrs, content|
  119. then: 0 else: 0 count = attrs[:count]&.to_i || 2
  120. render_columns(content, count)
  121. end
  122. # Alert/Notice shortcode
  123. 1 register('alert') do |attrs, content|
  124. type = attrs[:type] || 'info'
  125. render_alert(content, type)
  126. end
  127. # Code shortcode
  128. 1 register('code') do |attrs, content|
  129. language = attrs[:lang] || 'plaintext'
  130. render_code(content, language)
  131. end
  132. end
  133. # Rendering helpers
  134. 1 def render_gallery(media, columns, size)
  135. then: 0 else: 0 return '' if media.empty?
  136. html = '<div class="shortcode-gallery grid grid-cols-' + columns.to_s + ' gap-4 my-6">'
  137. media.each do |item|
  138. then: 0 else: 0 if item.file.attached?
  139. html += '<div class="gallery-item">'
  140. html += '<img src="' + Rails.application.routes.url_helpers.url_for(item.file) + '" alt="' + (item.alt_text || item.title) + '" class="w-full h-auto rounded-lg">'
  141. html += '</div>'
  142. end
  143. end
  144. html += '</div>'
  145. html
  146. end
  147. 1 def render_button(text, url, style, size, target)
  148. color_classes = {
  149. 'primary' => 'bg-blue-600 hover:bg-blue-700 text-white',
  150. 'secondary' => 'bg-gray-600 hover:bg-gray-700 text-white',
  151. 'success' => 'bg-green-600 hover:bg-green-700 text-white',
  152. 'danger' => 'bg-red-600 hover:bg-red-700 text-white'
  153. }
  154. size_classes = {
  155. 'small' => 'px-3 py-1 text-sm',
  156. 'medium' => 'px-6 py-2',
  157. 'large' => 'px-8 py-3 text-lg'
  158. }
  159. classes = "inline-block #{color_classes[style]} #{size_classes[size]} rounded-lg transition font-medium"
  160. "<a href=\"#{url}\" target=\"#{target}\" class=\"#{classes}\">#{text}</a>"
  161. end
  162. 1 def render_youtube(video_id, width, height)
  163. else: 0 then: 0 return '' unless video_id
  164. "<div class=\"video-container my-6\" style=\"position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;\">
  165. <iframe src=\"https://www.youtube.com/embed/#{video_id}\"
  166. width=\"#{width}\"
  167. height=\"#{height}\"
  168. style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%;\"
  169. frameborder=\"0\"
  170. allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"
  171. allowfullscreen>
  172. </iframe>
  173. </div>"
  174. end
  175. 1 def render_recent_posts(posts)
  176. then: 0 else: 0 return '' if posts.empty?
  177. html = '<div class="shortcode-recent-posts my-6 space-y-3">'
  178. posts.each do |post|
  179. html += '<div class="recent-post">'
  180. html += '<h4 class="font-semibold"><a href="/blog/' + post.slug + '" class="text-blue-600 hover:text-blue-800">' + post.title + '</a></h4>'
  181. html += '<p class="text-sm text-gray-500">' + post.published_at.strftime('%B %d, %Y') + '</p>'
  182. html += '</div>'
  183. end
  184. html += '</div>'
  185. html
  186. end
  187. 1 def render_contact_form(form_id, email)
  188. "<div class=\"shortcode-contact-form my-6 p-6 bg-gray-50 rounded-lg\">
  189. <form action=\"/contact\" method=\"post\" class=\"space-y-4\">
  190. <div>
  191. <label class=\"block text-sm font-medium mb-1\">Name</label>
  192. <input type=\"text\" name=\"name\" required class=\"w-full px-4 py-2 border rounded-lg\">
  193. </div>
  194. <div>
  195. <label class=\"block text-sm font-medium mb-1\">Email</label>
  196. <input type=\"email\" name=\"email\" required class=\"w-full px-4 py-2 border rounded-lg\">
  197. </div>
  198. <div>
  199. <label class=\"block text-sm font-medium mb-1\">Message</label>
  200. <textarea name=\"message\" rows=\"4\" required class=\"w-full px-4 py-2 border rounded-lg\"></textarea>
  201. </div>
  202. <button type=\"submit\" class=\"px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">Send Message</button>
  203. </form>
  204. </div>"
  205. end
  206. 1 def render_columns(content, count)
  207. "<div class=\"shortcode-columns grid grid-cols-#{count} gap-6 my-6\">
  208. #{content}
  209. </div>"
  210. end
  211. 1 def render_alert(content, type)
  212. colors = {
  213. 'info' => 'bg-blue-50 border-blue-500 text-blue-800',
  214. 'success' => 'bg-green-50 border-green-500 text-green-800',
  215. 'warning' => 'bg-yellow-50 border-yellow-500 text-yellow-800',
  216. 'danger' => 'bg-red-50 border-red-500 text-red-800'
  217. }
  218. "<div class=\"shortcode-alert #{colors[type]} border-l-4 p-4 my-6 rounded\">
  219. #{content}
  220. </div>"
  221. end
  222. 1 def render_code(content, language)
  223. "<pre class=\"shortcode-code my-6 bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto\"><code class=\"language-#{language}\">#{content}</code></pre>"
  224. end
  225. end
  226. end
  227. end

lib/railspress/theme_loader.rb

22.81% lines covered

5.36% branches covered

114 relevant lines. 26 lines covered and 88 lines missed.
56 total branches, 3 branches covered and 53 branches missed.
    
  1. 1 module Railspress
  2. 1 class ThemeLoader
  3. 1 class << self
  4. 1 attr_accessor :current_theme, :themes_path
  5. 1 def initialize_loader
  6. 1 @themes_path = Rails.root.join('app', 'themes')
  7. 1 @current_theme = nil
  8. 1 load_active_theme
  9. end
  10. # Load the active theme from database
  11. 1 def load_active_theme
  12. # Skip loading theme if database tables don't exist yet (e.g., during migrations)
  13. 1 else: 1 then: 0 return unless ActiveRecord::Base.connection.table_exists?('themes')
  14. 1 active_theme = Theme.active.first
  15. 1 then: 0 else: 1 if active_theme
  16. @current_theme = active_theme.name.underscore
  17. setup_theme_paths
  18. load_theme_initializer
  19. end
  20. end
  21. # Set up view paths for theme templates
  22. 1 def setup_theme_paths
  23. else: 0 then: 0 return unless @current_theme
  24. theme_views_path = Rails.root.join('app', 'themes', @current_theme, 'views')
  25. else: 0 if Dir.exist?(theme_views_path)
  26. then: 0 # Get current paths and add theme path at the beginning
  27. controller_paths = ActionController::Base.view_paths.paths.dup
  28. mailer_paths = ActionMailer::Base.view_paths.paths.dup
  29. # Remove any existing theme paths first
  30. controller_paths.reject! { |path| path.to_s.include?('app/themes') }
  31. mailer_paths.reject! { |path| path.to_s.include?('app/themes') }
  32. # Add new theme path at the beginning
  33. theme_resolver = ActionView::FileSystemResolver.new(theme_views_path.to_s)
  34. controller_paths.unshift(theme_resolver)
  35. mailer_paths.unshift(theme_resolver)
  36. # Set the new paths
  37. ActionController::Base.view_paths = ActionView::PathSet.new(controller_paths)
  38. ActionMailer::Base.view_paths = ActionView::PathSet.new(mailer_paths)
  39. end
  40. end
  41. # Load theme's initializer if exists
  42. 1 def load_theme_initializer
  43. else: 0 then: 0 return unless @current_theme
  44. initializer_path = Rails.root.join('app', 'themes', @current_theme, 'theme.rb')
  45. then: 0 else: 0 if File.exist?(initializer_path)
  46. load initializer_path
  47. Rails.logger.info "Loaded theme initializer: #{@current_theme}"
  48. end
  49. end
  50. # Get theme configuration from PublishedThemeVersion
  51. 1 def theme_config
  52. else: 0 then: 0 return {} unless @current_theme
  53. # First try to get from PublishedThemeVersion
  54. active_theme = Theme.active.first
  55. then: 0 else: 0 if active_theme
  56. published_version = PublishedThemeVersion.for_theme(active_theme.name).latest.first
  57. then: 0 else: 0 if published_version
  58. config_file = published_version.published_theme_files.find_by(file_path: 'config/theme.json')
  59. then: 0 else: 0 if config_file
  60. return JSON.parse(config_file.content)
  61. end
  62. end
  63. end
  64. # Fallback to filesystem
  65. config_path = Rails.root.join('app', 'themes', @current_theme, 'config', 'theme.json')
  66. then: 0 if File.exist?(config_path)
  67. JSON.parse(File.read(config_path))
  68. else: 0 else
  69. {}
  70. end
  71. end
  72. # Get all available themes
  73. 1 def available_themes
  74. else: 0 then: 0 return [] unless Dir.exist?(@themes_path)
  75. Dir.glob(@themes_path.join('*')).select { |f| File.directory?(f) }.map do |theme_dir|
  76. theme_name = File.basename(theme_dir)
  77. config_path = File.join(theme_dir, 'config', 'theme.json')
  78. then: 0 if File.exist?(config_path)
  79. config = JSON.parse(File.read(config_path))
  80. {
  81. name: theme_name,
  82. display_name: config['name'] || theme_name.titleize,
  83. version: config['version'] || '1.0.0',
  84. author: config['author'] || 'Unknown',
  85. description: config['description'] || 'No description',
  86. screenshot: config['screenshot'] || nil,
  87. path: theme_dir
  88. }
  89. else: 0 else
  90. {
  91. name: theme_name,
  92. display_name: theme_name.titleize,
  93. version: '1.0.0',
  94. author: 'Unknown',
  95. description: 'No description',
  96. screenshot: nil,
  97. path: theme_dir
  98. }
  99. end
  100. end
  101. end
  102. # Activate a theme
  103. 1 def activate_theme(theme_name)
  104. theme_path = @themes_path.join(theme_name)
  105. else: 0 then: 0 unless Dir.exist?(theme_path)
  106. Rails.logger.error "Theme not found: #{theme_name}"
  107. return false
  108. end
  109. # Update database
  110. Theme.where.not(name: theme_name.camelize).update_all(active: false)
  111. theme_record = Theme.find_or_create_by(name: theme_name.camelize) do |t|
  112. config_path = theme_path.join('config', 'theme.json')
  113. then: 0 else: 0 config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
  114. t.description = config['description'] || 'No description'
  115. t.author = config['author'] || 'Unknown'
  116. t.version = config['version'] || '1.0.0'
  117. end
  118. theme_record.update(active: true)
  119. # Clear old theme paths
  120. clear_theme_paths
  121. # Reload theme
  122. @current_theme = theme_name
  123. setup_theme_paths
  124. load_theme_initializer
  125. # Clear view cache
  126. ActionView::LookupContext::DetailsKey.clear
  127. # Clear Rails cache
  128. then: 0 else: 0 Rails.cache.clear if Rails.cache.respond_to?(:clear)
  129. Rails.logger.info "Activated theme: #{theme_name}"
  130. true
  131. end
  132. # Clear theme-specific view paths
  133. 1 def clear_theme_paths
  134. # Remove old theme view paths
  135. then: 0 else: 0 if @current_theme
  136. old_theme_views = Rails.root.join('app', 'themes', @current_theme, 'views')
  137. # Create new view paths without theme paths (since the array might be frozen)
  138. controller_paths = ActionController::Base.view_paths.paths.reject { |path| path.to_s.include?('app/themes') }
  139. mailer_paths = ActionMailer::Base.view_paths.paths.reject { |path| path.to_s.include?('app/themes') }
  140. # Set the new paths
  141. ActionController::Base.view_paths = ActionView::PathSet.new(controller_paths)
  142. ActionMailer::Base.view_paths = ActionView::PathSet.new(mailer_paths)
  143. end
  144. end
  145. # Get theme asset path
  146. 1 def theme_asset_path(asset_type)
  147. else: 0 then: 0 return nil unless @current_theme
  148. Rails.root.join('app', 'themes', @current_theme, 'assets', asset_type)
  149. end
  150. # Get theme stylesheet
  151. 1 def theme_stylesheet
  152. else: 0 then: 0 return nil unless @current_theme
  153. stylesheet_path = theme_asset_path('stylesheets')
  154. else: 0 then: 0 return nil unless stylesheet_path && Dir.exist?(stylesheet_path)
  155. # Look for main stylesheet
  156. main_css = Dir.glob(stylesheet_path.join('*.css')).first
  157. then: 0 else: 0 main_css ? File.basename(main_css, '.css') : nil
  158. end
  159. # Get theme javascript
  160. 1 def theme_javascript
  161. else: 0 then: 0 return nil unless @current_theme
  162. js_path = theme_asset_path('javascripts')
  163. else: 0 then: 0 return nil unless js_path && Dir.exist?(js_path)
  164. # Look for main javascript
  165. main_js = Dir.glob(js_path.join('*.js')).first
  166. then: 0 else: 0 main_js ? File.basename(main_js, '.js') : nil
  167. end
  168. # Check if template exists in theme
  169. 1 def template_exists?(template_path)
  170. else: 0 then: 0 return false unless @current_theme
  171. full_path = Rails.root.join('app', 'themes', @current_theme, 'views', "#{template_path}.html.erb")
  172. File.exist?(full_path)
  173. end
  174. # Get theme helper modules
  175. 1 def theme_helpers
  176. 1 else: 0 then: 1 return [] unless @current_theme
  177. helpers_path = Rails.root.join('app', 'themes', @current_theme, 'helpers')
  178. else: 0 then: 0 return [] unless Dir.exist?(helpers_path)
  179. Dir.glob(helpers_path.join('*.rb')).map do |helper_file|
  180. require helper_file
  181. File.basename(helper_file, '.rb').camelize.constantize
  182. end
  183. end
  184. 1 private
  185. 1 def load_theme_config(theme_name)
  186. config_path = @themes_path.join(theme_name, 'config.yml')
  187. then: 0 else: 0 File.exist?(config_path) ? YAML.load_file(config_path) : {}
  188. end
  189. end
  190. end
  191. end

lib/railspress/update_checker.rb

0.0% lines covered

100.0% branches covered

104 relevant lines. 0 lines covered and 104 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require 'net/http'
  3. require 'json'
  4. module Railspress
  5. class UpdateChecker
  6. GITHUB_REPO = ENV['RAILSPRESS_GITHUB_REPO'] || 'username/railspress'
  7. GITHUB_API_URL = "https://api.github.com/repos/#{GITHUB_REPO}/releases/latest"
  8. CURRENT_VERSION = '1.0.0'
  9. class << self
  10. def check_for_updates
  11. return cached_result if cached_result && cache_valid?
  12. begin
  13. latest_version = fetch_latest_version
  14. update_available = version_greater?(latest_version, CURRENT_VERSION)
  15. result = {
  16. current_version: CURRENT_VERSION,
  17. latest_version: latest_version,
  18. update_available: update_available,
  19. checked_at: Time.current,
  20. release_url: "https://github.com/#{GITHUB_REPO}/releases/latest"
  21. }
  22. cache_result(result)
  23. result
  24. rescue => e
  25. Rails.logger.error("Update check failed: #{e.message}")
  26. {
  27. current_version: CURRENT_VERSION,
  28. latest_version: nil,
  29. update_available: false,
  30. error: e.message,
  31. checked_at: Time.current
  32. }
  33. end
  34. end
  35. def fetch_latest_version
  36. uri = URI(GITHUB_API_URL)
  37. request = Net::HTTP::Get.new(uri)
  38. request['Accept'] = 'application/vnd.github.v3+json'
  39. request['User-Agent'] = 'RailsPress'
  40. # Add GitHub token if available
  41. if ENV['GITHUB_TOKEN']
  42. request['Authorization'] = "token #{ENV['GITHUB_TOKEN']}"
  43. end
  44. response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  45. http.request(request)
  46. end
  47. if response.code == '200'
  48. data = JSON.parse(response.body)
  49. data['tag_name'].gsub(/^v/, '') # Remove 'v' prefix if present
  50. else
  51. raise "GitHub API returned #{response.code}: #{response.body}"
  52. end
  53. end
  54. def version_greater?(version1, version2)
  55. v1_parts = version1.split('.').map(&:to_i)
  56. v2_parts = version2.split('.').map(&:to_i)
  57. [v1_parts.length, v2_parts.length].max.times do |i|
  58. v1 = v1_parts[i] || 0
  59. v2 = v2_parts[i] || 0
  60. return true if v1 > v2
  61. return false if v1 < v2
  62. end
  63. false
  64. end
  65. def fetch_release_notes
  66. uri = URI(GITHUB_API_URL)
  67. request = Net::HTTP::Get.new(uri)
  68. request['Accept'] = 'application/vnd.github.v3+json'
  69. request['User-Agent'] = 'RailsPress'
  70. if ENV['GITHUB_TOKEN']
  71. request['Authorization'] = "token #{ENV['GITHUB_TOKEN']}"
  72. end
  73. response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  74. http.request(request)
  75. end
  76. if response.code == '200'
  77. data = JSON.parse(response.body)
  78. {
  79. version: data['tag_name'],
  80. name: data['name'],
  81. body: data['body'],
  82. html_url: data['html_url'],
  83. published_at: data['published_at']
  84. }
  85. else
  86. nil
  87. end
  88. end
  89. private
  90. def cache_key
  91. 'railspress:update_check'
  92. end
  93. def cached_result
  94. Rails.cache.read(cache_key)
  95. end
  96. def cache_result(result)
  97. # Cache for 6 hours
  98. Rails.cache.write(cache_key, result, expires_in: 6.hours)
  99. end
  100. def cache_valid?
  101. cached = cached_result
  102. return false unless cached
  103. cached[:checked_at] && cached[:checked_at] > 6.hours.ago
  104. end
  105. end
  106. end
  107. end

lib/railspress/webhook_dispatcher.rb

0.0% lines covered

100.0% branches covered

116 relevant lines. 0 lines covered and 116 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Railspress
  3. class WebhookDispatcher
  4. class << self
  5. # Dispatch a webhook event
  6. def dispatch(event_type, resource)
  7. # Find all active webhooks subscribed to this event
  8. webhooks = Webhook.active.for_event(event_type)
  9. return if webhooks.empty?
  10. # Build payload
  11. payload = build_payload(event_type, resource)
  12. # Deliver to each webhook
  13. webhooks.each do |webhook|
  14. webhook.deliver(event_type, payload)
  15. end
  16. Rails.logger.info "Dispatched webhook event: #{event_type} to #{webhooks.count} webhook(s)"
  17. end
  18. private
  19. def build_payload(event_type, resource)
  20. base_payload = {
  21. event: event_type,
  22. timestamp: Time.current.iso8601,
  23. data: serialize_resource(resource)
  24. }
  25. # Add site context
  26. base_payload[:site] = {
  27. name: SiteSetting.get('site_title', 'RailsPress'),
  28. url: site_url
  29. }
  30. base_payload
  31. end
  32. def serialize_resource(resource)
  33. case resource
  34. when Post
  35. {
  36. id: resource.id,
  37. type: 'post',
  38. title: resource.title,
  39. slug: resource.slug,
  40. excerpt: resource.excerpt,
  41. status: resource.status,
  42. published_at: resource.published_at&.iso8601,
  43. url: post_url(resource),
  44. author: {
  45. id: resource.user&.id,
  46. email: resource.user&.email
  47. },
  48. categories: resource.category.map { |c| { id: c.id, name: c.name, slug: c.slug } },
  49. tags: resource.post_tag.map { |t| { id: t.id, name: t.name, slug: t.slug } },
  50. created_at: resource.created_at.iso8601,
  51. updated_at: resource.updated_at.iso8601
  52. }
  53. when Page
  54. {
  55. id: resource.id,
  56. type: 'page',
  57. title: resource.title,
  58. slug: resource.slug,
  59. status: resource.status,
  60. published_at: resource.published_at&.iso8601,
  61. url: page_url(resource),
  62. author: {
  63. id: resource.user&.id,
  64. email: resource.user&.email
  65. },
  66. created_at: resource.created_at.iso8601,
  67. updated_at: resource.updated_at.iso8601
  68. }
  69. when Comment
  70. {
  71. id: resource.id,
  72. type: 'comment',
  73. content: resource.content,
  74. author_name: resource.author_name,
  75. author_email: resource.author_email,
  76. status: resource.status,
  77. commentable_type: resource.commentable_type,
  78. commentable_id: resource.commentable_id,
  79. created_at: resource.created_at.iso8601,
  80. updated_at: resource.updated_at.iso8601
  81. }
  82. when User
  83. {
  84. id: resource.id,
  85. type: 'user',
  86. email: resource.email,
  87. role: resource.role,
  88. created_at: resource.created_at.iso8601,
  89. updated_at: resource.updated_at.iso8601
  90. }
  91. when Medium
  92. {
  93. id: resource.id,
  94. type: 'media',
  95. title: resource.title,
  96. created_at: resource.created_at.iso8601
  97. }
  98. else
  99. {
  100. id: resource.try(:id),
  101. type: resource.class.name.underscore
  102. }
  103. end
  104. end
  105. def site_url
  106. Rails.application.routes.url_helpers.root_url
  107. rescue
  108. 'http://localhost:3000'
  109. end
  110. def post_url(post)
  111. Rails.application.routes.url_helpers.blog_post_url(post.slug)
  112. rescue
  113. "#{site_url}/blog/#{post.slug}"
  114. end
  115. def page_url(page)
  116. Rails.application.routes.url_helpers.page_url(page.slug)
  117. rescue
  118. "#{site_url}/#{page.slug}"
  119. end
  120. end
  121. end
  122. end